//! 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, } /// 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, /// TRACES: FR-DEV-3f /// The stock this edit develops on, named. pub film: Option, /// 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()); } }