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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user