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>
314 lines
13 KiB
Rust
314 lines
13 KiB
Rust
//! 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());
|
|
}
|
|
}
|