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:
@@ -0,0 +1,313 @@
|
||||
//! TRACES: FR-DEV-5 | FR-CAT-8
|
||||
//! The whole of an edit, gathered so that nothing can be left out of it.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! An edit used to be a bag of scalars, and [`Preset`] is that bag. It was a
|
||||
//! true description while the graph held only operations and a framing, and it
|
||||
//! stopped being true the moment a mask stack and a film stock arrived —
|
||||
//! because a layer is not a scalar and a stock is not a scalar, which is
|
||||
//! precisely why [`EditGraph`] holds both of them apart from `ops`.
|
||||
//!
|
||||
//! Nothing announced the change. What happened instead is that three places
|
||||
//! each captured "the edit" and each captured a different subset of it:
|
||||
//! [`Preset::capture`] took the parameters, `Version::from_graph` took the
|
||||
//! parameters and the masks and the film, and `Version::update` took the
|
||||
//! parameters and the masks and dropped the film on the floor. The visible
|
||||
//! symptom was quieter still — the history snapshots a `Preset`, so drawing a
|
||||
//! mask opened no undo step at all, and `record` returned `false` while the
|
||||
//! interface went on calling it in good faith.
|
||||
//!
|
||||
//! # What keeps it complete
|
||||
//!
|
||||
//! Not vigilance, and not a checklist in a comment. [`EditGraph::state`]
|
||||
//! destructures the graph **exhaustively** and [`EditGraph::set_state`]
|
||||
//! destructures this type exhaustively — no `..` in either pattern, and this
|
||||
//! type's fields are public so that every construction site is a struct
|
||||
//! literal naming all of them. A fifth kind of state added to the graph does
|
||||
//! not compile until somebody has decided whether an undo has to put it back.
|
||||
//!
|
||||
//! FR-DEV-8's spot removal was named here as the one already asked for, and it
|
||||
//! arrived. It did not compile, which is the whole of what this was for: the
|
||||
//! repairs are in [`Self::spots`] because the build refused to proceed without
|
||||
//! an answer about them, rather than because anyone remembered to look.
|
||||
//!
|
||||
//! That is the whole mechanism. It is deliberately a compiler error rather
|
||||
//! than a runtime check, because the failure being prevented is *silence*: the
|
||||
//! mask bug produced no panic, no warning and no failing test, and only a
|
||||
//! diagnostic that fires before the code runs at all could have caught it.
|
||||
//!
|
||||
//! # What is deliberately not in here
|
||||
//!
|
||||
//! - **The viewport.** Zoom and pan say where the photographer is looking, not
|
||||
//! what the photograph becomes. An undo that also moved the view would read
|
||||
//! as navigation — the rule [`Preset::apply`] already keeps for a paste.
|
||||
//! - **The orientation baseline.** How the camera stored its rows is part of
|
||||
//! reading the file, not a decision anyone made (see [`crate::framing`]).
|
||||
//! - **The film tables.** Derived from the stock, the paper and the film
|
||||
//! node's own exposure sliders, and re-baked in milliseconds. Turning a name
|
||||
//! back into tables needs the profile database this crate does not link
|
||||
//! (ARCH §6.5a), which is why [`FilmRebake`] exists.
|
||||
//! - **The rating and the flag.** Judgements about the photograph rather than
|
||||
//! edits to it; they change no pixel, and `sidecar::Version` keeps them for
|
||||
//! that reason.
|
||||
|
||||
use crate::graph::EditGraph;
|
||||
use crate::mask::MaskStack;
|
||||
use crate::preset::Preset;
|
||||
use crate::spot::SpotSet;
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The stock and paper an edit names, without the tables they bake to.
|
||||
///
|
||||
/// The **id, not an index**. Stocks are files that users add
|
||||
/// (`core/dr-film/profiles`), so an index would mean installing a profile
|
||||
/// silently changed which film every existing photograph was developed on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct FilmRef {
|
||||
pub stock: String,
|
||||
/// The paper, if the negative is printed. Absent means the film is viewed
|
||||
/// as it comes — which for a colour negative is the scan, orange and
|
||||
/// inverted, and is a legitimate thing to ask for.
|
||||
pub print: Option<String>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5 | FR-CAT-8
|
||||
/// Everything about a photograph that an edit decides.
|
||||
///
|
||||
/// Fields are public on purpose: a struct literal is exhaustive, so every
|
||||
/// place that builds one has to account for every part of an edit. See the
|
||||
/// module note — that is the entire safety mechanism, and hiding these behind
|
||||
/// a constructor with positional arguments would trade it for two `String`s
|
||||
/// that can be passed in the wrong order.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct EditState {
|
||||
/// Every non-default parameter — the operations' and the framing's alike,
|
||||
/// since the framing is an entry in [`EditGraph::capabilities`] like any
|
||||
/// other.
|
||||
pub params: Preset,
|
||||
/// The local adjustments (FR-DEV-3).
|
||||
///
|
||||
/// Shared rather than owned, and that is a decision about the *drag path*
|
||||
/// rather than about memory. `state` is called on every parameter change,
|
||||
/// which during a slider drag is once a frame; a mask stack holding a
|
||||
/// painted brush is thousands of stroke points, and deep-copying it sixty
|
||||
/// times a second to record an exposure move that did not touch it would
|
||||
/// be a cost paid for nothing. [`EditGraph::masks_mut`] is the single door
|
||||
/// through which a stack is modified, and it clones on write, so snapshots
|
||||
/// share until one of them is actually edited.
|
||||
pub masks: std::sync::Arc<MaskStack>,
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// The stock this edit develops on, named.
|
||||
pub film: Option<FilmRef>,
|
||||
/// TRACES: FR-DEV-8
|
||||
/// The repairs (see [`crate::spot`]).
|
||||
///
|
||||
/// Owned rather than shared, where [`Self::masks`] is shared, and the
|
||||
/// difference is a fact about the two rather than an inconsistency: a
|
||||
/// repair is a handful of numbers and a photographer places tens of them,
|
||||
/// while a painted mask is an unbounded path of stroke points. Copying the
|
||||
/// first once a frame is nothing; copying the second is the reason
|
||||
/// `masks_mut` clones on write.
|
||||
pub spots: SpotSet,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// What a caller still owes the graph after its state was replaced.
|
||||
///
|
||||
/// `dr-pipeline` can hold a film but cannot bake one: the tables come from the
|
||||
/// profile database, and this crate does not link it (ARCH §6.5a). So
|
||||
/// restoring an edit that names a stock is necessarily two steps, and this is
|
||||
/// the second one made impossible to forget rather than left in a comment.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[must_use = "an ignored rebake leaves the photograph rendering without its film"]
|
||||
pub enum FilmRebake {
|
||||
/// The graph's film is already right — either the state named none, in
|
||||
/// which case it has been cleared, or there was nothing to restore.
|
||||
NotNeeded,
|
||||
/// Bake this stock and hand the result back through
|
||||
/// [`EditGraph::set_film`].
|
||||
Wanted(FilmRef),
|
||||
}
|
||||
|
||||
impl FilmRebake {
|
||||
/// The stock to bake, if one is owed.
|
||||
pub fn wanted(&self) -> Option<&FilmRef> {
|
||||
match self {
|
||||
Self::NotNeeded => None,
|
||||
Self::Wanted(film) => Some(film),
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert that nothing is owed, for a caller that knows this edit names
|
||||
/// no film — a test fixture, in practice.
|
||||
///
|
||||
/// It *checks* rather than discards, and that is the point of it existing
|
||||
/// at all. `let _ = …` would make the same claim silently and go on making
|
||||
/// it the day the fixture grows a stock, at which point the test would
|
||||
/// pass while rendering the wrong picture — which is precisely the failure
|
||||
/// this type was introduced to stop.
|
||||
#[track_caller]
|
||||
pub fn expect_no_film(self) {
|
||||
if let Self::Wanted(film) = self {
|
||||
panic!(
|
||||
"this edit develops on {:?}, which has to be baked back before \
|
||||
the photograph can be rendered",
|
||||
film.stock
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditState {
|
||||
/// The state `graph` is in. The same call as [`EditGraph::state`], for a
|
||||
/// caller that reads better this way round.
|
||||
pub fn capture(graph: &EditGraph) -> Self {
|
||||
graph.state()
|
||||
}
|
||||
|
||||
/// Whether this edit does anything to the photograph at all.
|
||||
///
|
||||
/// A neutral state is a *decision* rather than a missing one — applying it
|
||||
/// returns the photograph to its defaults — so this answers "is there
|
||||
/// anything to show", not "is there anything to store".
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
self.params.is_empty()
|
||||
&& self.masks.is_empty()
|
||||
&& self.film.is_none()
|
||||
&& self.spots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::framing;
|
||||
use crate::graph::Film;
|
||||
use crate::mask::{MaskLayer, MaskSource};
|
||||
use crate::ops::{curve, exposure, saturation};
|
||||
use crate::spot::Spot;
|
||||
use crate::CropRect;
|
||||
|
||||
/// A graph with one of *every* kind of state moved off its default: an
|
||||
/// operation's parameter, a framing, a mask layer, a film, a repair. One
|
||||
/// of each is the point — a round trip that only exercises the scalars is
|
||||
/// the test that was already passing while the masks went missing.
|
||||
fn thoroughly_edited() -> EditGraph {
|
||||
let mut g = EditGraph::default_chain();
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
|
||||
g.set_param(saturation::ID, saturation::SATURATION, -30.0);
|
||||
g.set_param(curve::ID, curve::P1_X, 0.3);
|
||||
g.set_param(framing::ID, framing::ANGLE, 2.5);
|
||||
g.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.6,
|
||||
height: 0.6,
|
||||
});
|
||||
|
||||
let mut layer = MaskLayer::new(
|
||||
"l1",
|
||||
MaskSource::Radial {
|
||||
centre: (0.4, 0.6),
|
||||
radii: (0.25, 0.3),
|
||||
angle: 0.2,
|
||||
feather: 0.15,
|
||||
},
|
||||
);
|
||||
layer.opacity = 0.6;
|
||||
layer.invert = true;
|
||||
g.masks_mut().push(layer);
|
||||
|
||||
g.spots_mut()
|
||||
.place(Spot::new((0.3, 0.7), (0.05, 0.0), 0.02))
|
||||
.expect("the repair was placed");
|
||||
|
||||
g.set_film(Some(Film {
|
||||
stock: "kodak_portra_400".into(),
|
||||
print: Some("kodak_endura".into()),
|
||||
tables: crate::ops::FilmTables {
|
||||
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
|
||||
curve_log_min: -3.0,
|
||||
curve_log_max: 1.0,
|
||||
lut: vec![[0.5, 0.5, 0.5]; 8],
|
||||
density_max: 2.0,
|
||||
lut_size: 2,
|
||||
grain_particles: [0.0; 3],
|
||||
grain_density_max: [2.0; 3],
|
||||
grain_uniformity: 1.0,
|
||||
},
|
||||
}));
|
||||
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_survives_being_taken_off_a_graph_and_put_back() {
|
||||
// The property every undo rests on. Checked over the whole state
|
||||
// rather than the values that were set, because the interesting
|
||||
// failure is not a value coming back wrong — it is a *kind* of value
|
||||
// that was never carried at all, and a narrower assertion is exactly
|
||||
// what missed the masks.
|
||||
let edited = thoroughly_edited();
|
||||
let state = edited.state();
|
||||
|
||||
let mut fresh = EditGraph::default_chain();
|
||||
let rebake = fresh.set_state(&state);
|
||||
|
||||
assert_eq!(
|
||||
rebake,
|
||||
FilmRebake::Wanted(FilmRef {
|
||||
stock: "kodak_portra_400".into(),
|
||||
print: Some("kodak_endura".into()),
|
||||
}),
|
||||
"the stock has to be asked for, since this crate cannot bake it"
|
||||
);
|
||||
|
||||
// The film is the one part `set_state` deliberately does not restore,
|
||||
// so it is put back the way a caller would before comparing.
|
||||
fresh.set_film(edited.film().cloned());
|
||||
|
||||
assert_eq!(
|
||||
fresh.state(),
|
||||
state,
|
||||
"something about the edit did not survive the round trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn putting_a_state_back_replaces_rather_than_overlays() {
|
||||
// A part absent from the state means *default*, not "leave whatever
|
||||
// was there". Otherwise undoing to the floor would leave the last
|
||||
// mask standing, which is the shape of the bug this type exists for.
|
||||
let mut g = EditGraph::default_chain();
|
||||
let floor = g.state();
|
||||
|
||||
let edited = thoroughly_edited();
|
||||
assert!(g.set_state(&edited.state()).wanted().is_some());
|
||||
// A caller bakes at this point. Standing in for it here is what makes
|
||||
// the assertion below mean anything: without a film on the graph,
|
||||
// "the film was cleared" would be true of a graph that never had one.
|
||||
g.set_film(edited.film().cloned());
|
||||
assert!(g.film().is_some() && !g.masks().is_empty() && !g.spots().is_empty());
|
||||
|
||||
g.set_state(&floor).expect_no_film();
|
||||
|
||||
assert!(
|
||||
g.masks().is_empty(),
|
||||
"a layer outlived the state holding it"
|
||||
);
|
||||
assert!(g.film().is_none(), "the film outlived the state holding it");
|
||||
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
||||
assert!(g.crop().is_full(), "the crop outlived it: {:?}", g.crop());
|
||||
assert_eq!(g.state(), floor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_state_is_a_decision_rather_than_a_missing_one() {
|
||||
assert!(EditGraph::default_chain().state().is_neutral());
|
||||
assert!(!thoroughly_edited().state().is_neutral());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user