Give an edit one complete state, and make omitting part of it a compile error

An edit used to be a bag of scalars. That stopped being true when the mask
stack and the film stock arrived, both deliberately held apart from `ops`
because a layer is not a scalar and a stock is not a scalar — and nothing
announced the change. What happened instead is that three routines each
captured "the edit" and each captured a different subset of it.

`EditState` is all of it: the parameter map, the masks, the stock. What keeps
it complete is not a comment. `EditGraph::state` destructures the graph
exhaustively, `EditGraph::set_state` destructures the state exhaustively, and
the fields are public so every construction site is a struct literal naming
all of them. Adding a fifth kind of graph state — FR-DEV-8's spot removal is
the one already asked for — fails to compile until somebody has decided
whether an undo has to put it back. Verified both ways round by adding a field
to each type and watching five call sites refuse to build.

A compiler error rather than a runtime check, because the failure being
prevented is silence: the missing halves produced no panic, no warning and no
failing test.

The stack is now shared rather than owned, and that is about the drag path
rather than memory: `state` runs on every parameter change, which during a
drag is once a frame, and deep-copying a painted brush sixty times a second to
record an exposure move would be a cost paid for nothing. `masks_mut` is the
one door a stack is modified through, so it clones on write.

`FilmRebake` is the one thing a caller is still owed. Restoring a stock always
needed the profile database this crate does not link (ARCH §6.5a); it was a
comment before, and it is a `#[must_use]` return value now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:50:11 +02:00
co-authored by Claude Opus 5
parent 4a82753d22
commit 5622a58ce3
5 changed files with 462 additions and 57 deletions
+98 -4
View File
@@ -7,6 +7,8 @@
//! Order is data, not code: operations run in the sequence this holds them,
//! so reordering the pipeline needs no code change.
use std::sync::Arc;
use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
@@ -14,7 +16,9 @@ use crate::framing::{CropRect, Framing};
use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation};
use crate::ops;
use crate::preset::{Preset, Scope};
use crate::spot::SpotSet;
use crate::state::{EditState, FilmRebake, FilmRef};
/// TRACES: FR-DEV-3a
/// What one operation offers, as plain data.
@@ -93,7 +97,7 @@ pub struct EditGraph {
/// layer *contains* a chain of its own. Folding the stack into the global
/// list would make the list recursive and every consumer that walks it
/// have to know that some entries are really sub-graphs.
masks: MaskStack,
masks: Arc<MaskStack>,
/// TRACES: FR-DEV-3f
/// The film stock this edit renders through, if any.
///
@@ -156,7 +160,7 @@ impl EditGraph {
Self {
ops: ops::chain(),
framing: Framing::new(),
masks: MaskStack::new(),
masks: Arc::new(MaskStack::new()),
film: None,
spots: SpotSet::new(),
}
@@ -199,8 +203,15 @@ impl EditGraph {
&self.masks
}
/// The stack, to modify.
///
/// Clones on write. The stack is shared with every [`EditState`] snapshot
/// taken since it last changed — the undo stack holds a run of them — so
/// this is where a shared stack becomes this graph's own again. Callers
/// see no difference; what it buys is that recording a slider drag does
/// not deep-copy a painted mask once a frame.
pub fn masks_mut(&mut self) -> &mut MaskStack {
&mut self.masks
Arc::make_mut(&mut self.masks)
}
/// The framing — crop, straighten, rotation and flips.
@@ -331,6 +342,89 @@ impl EditGraph {
self.film.as_ref()
}
/// TRACES: FR-DEV-5 | FR-CAT-8
/// The whole edit, as data — what an undo step and a sidecar are both
/// made of.
///
/// **The pattern below is exhaustive on purpose.** It is the only thing
/// standing between a new kind of graph state and an undo that quietly
/// ignores it, which is exactly how the mask stack came to be missing
/// from the history for as long as it was. Never add `..` to it: a field
/// added to this struct should fail to compile here until somebody has
/// decided whether stepping backwards has to put it back. See
/// [`crate::state`].
pub fn state(&self) -> EditState {
let Self {
// Both reached through `capabilities`, which is the one walk the
// develop panel, the clipboard and the sidecar already make — so
// an operation is undoable by virtue of being in the chain, with
// nothing to register (FR-DEV-3c).
ops: _,
framing: _,
masks,
film,
spots,
} = self;
EditState {
params: Preset::capture(self),
// A refcount bump. See `EditState::masks` for why that matters on
// a path called once a frame.
masks: Arc::clone(masks),
// The names travel; the tables do not. They are derived, and this
// crate cannot rebuild them — hence `FilmRebake`.
film: film.as_ref().map(|f| FilmRef {
stock: f.stock.clone(),
print: f.print.clone(),
}),
spots: spots.clone(),
}
}
/// TRACES: FR-DEV-5 | FR-CAT-8
/// Put `state` back, replacing whatever this graph held.
///
/// A *replacement*, not an overlay: a parameter absent from the state
/// means default, and a state with no mask blocks means an edit with no
/// local adjustments rather than an edit that keeps whatever was on
/// screen. That is the same rule [`Preset::apply`] and
/// [`crate::Version::apply`] keep, and for the same reason — "the
/// photograph is now as it was" is the whole claim the call makes.
///
/// The **viewport survives**, because [`Preset::apply`] preserves it. Zoom
/// says where the user is looking rather than what the picture is, and an
/// undo that refitted the frame would read as having navigated somewhere.
///
/// The pattern below is exhaustive for the reason [`Self::state`]'s is.
pub fn set_state(&mut self, state: &EditState) -> FilmRebake {
let EditState {
params,
masks,
film,
spots,
} = state;
// At full scope. `Scope` is a question about what a paste carries
// *between* photographs; this is one photograph's own edit being put
// back, so there is nothing to leave behind.
params.apply(self, Scope::Everything);
self.masks = Arc::clone(masks);
self.spots = spots.clone();
// Cleared either way, and when a stock is named the caller bakes it.
// Left standing, the tables now in the graph would be the ones baked
// from the film node's *previous* exposure sliders — and those sliders
// were just replaced, so the restored state would render through the
// film of the state it replaced. Clearing is the conservative half of
// that; `Wanted` is the half that gets it back.
self.set_film(None);
match film {
None => FilmRebake::NotNeeded,
Some(want) => FilmRebake::Wanted(want.clone()),
}
}
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::framing::ID {
let Some(desc) = self.framing.descriptor().param(param) else {
@@ -383,7 +477,7 @@ impl EditGraph {
// Masks go too, and this is why `apply` can be a replacement rather
// than an overlay: a sidecar with no mask blocks means an edit with no
// local adjustments, not an edit that keeps whatever was on screen.
self.masks = MaskStack::new();
self.masks = Arc::new(MaskStack::new());
// The film goes too, for the reason the masks do. Restoring it is the
// *caller's* job rather than `Version::apply`'s: a sidecar names a
// stock, and turning a name into tables needs the profile database,