diff --git a/core/dr-pipeline/src/history.rs b/core/dr-pipeline/src/history.rs index 5ca977f..401c883 100644 --- a/core/dr-pipeline/src/history.rs +++ b/core/dr-pipeline/src/history.rs @@ -3,10 +3,9 @@ //! //! # A stack of snapshots, not a stack of commands //! -//! The edit graph is plain data, and [`Preset::capture`] already reduces it to -//! the values that differ from default. So a history is a list of those, and -//! undo is [`Preset::apply`] at full scope — the same two calls the clipboard -//! and the sidecar are built from. +//! The edit graph is plain data, and [`EditGraph::state`] reduces it to +//! exactly what an edit is. So a history is a list of those, and undo is +//! [`EditGraph::set_state`] — the same two calls the sidecar is built from. //! //! The alternative, a command per action with an inverse beside it, would be a //! second thing every operation had to register. Operations are *declared* @@ -15,6 +14,34 @@ //! A snapshot knows nothing about which operations exist, so it cannot fall //! behind them. //! +//! # The snapshot has to be of the whole edit +//! +//! That last claim held for the operations and stopped holding for everything +//! else. This stack used to snapshot a [`Preset`](crate::Preset), which is the +//! parameter map — and a mask layer is not a parameter, a film stock is not a +//! parameter, and a repair is not a parameter, all three deliberately so. +//! +//! It went wrong three times, in two different ways, which is the argument for +//! not leaving it to anyone's memory: +//! +//! - **The masks and the stock went missing quietly.** Drawing a mask changed +//! nothing a `Preset` could see, so [`History::record`] returned `false`, no +//! step was opened, and the interface went on calling it in good faith. The +//! layer a photographer had just painted had no way back, and nothing +//! panicked, no test failed, and the only signal available was a `bool` +//! nobody was reading. +//! +//! - **The repairs would have gone missing loudly.** Undo is the single most +//! expected thing to do with a spot — place it, dislike it, take it back — +//! and a snapshot that could not carry the spot set would have stepped some +//! unrelated slider instead and left the repair on the photograph. That is +//! worse than no undo at all, because it looks like undo is *broken* rather +//! than absent. It did not happen: [`EditState`] refused to compile when the +//! spot set arrived in the graph, which is what that type is for. +//! +//! So the snapshot is an [`EditState`], and what makes that stay true is a +//! compiler error rather than a habit — see [`crate::state`]. +//! //! # What makes forty events one step //! //! A drag emits a change per frame. Recorded naively that is forty undo steps, @@ -42,15 +69,17 @@ //! # What this is not, yet //! //! FR-DEV-5 also asks for history persisted with the catalog and named -//! snapshots. This is per-session and in memory: closing the image forgets it. -//! The gap that mattered was that an automatically saved mis-drag had no way -//! back at all, and that is what this closes. +//! snapshots, and FR-DEV-7 for comparing against a chosen history state. +//! This is per-session and in memory, with no way to name a step or to jump +//! to one: closing the image forgets it. The gap that mattered was that an +//! automatically saved mis-drag had no way back at all, and that is what this +//! closes. use std::time::{Duration, Instant}; use crate::descriptor::{OpId, ParamId}; use crate::graph::EditGraph; -use crate::preset::{Preset, Scope}; +use crate::state::{EditState, FilmRebake}; /// TRACES: FR-DEV-5 | NFR-RES-1 /// How many states are held, the current one included. @@ -121,6 +150,42 @@ impl Edit { } } +/// TRACES: FR-DEV-5 +/// What one press of undo or redo did. +/// +/// Not a `bool`, because stepping is not the only outcome a caller has to act +/// on: a step that crosses a change of film leaves the graph without its +/// tables, and only the caller can bake them back (see [`FilmRebake`]). A +/// `bool` would let that be dropped by writing nothing at all, which is the +/// shape of mistake this module has already made once. +#[derive(Debug, Clone, PartialEq)] +#[must_use = "a step that is not acted on leaves the panel showing the old values"] +pub enum Step { + /// Nowhere to go — the stack is at its floor, or at its head. + Nowhere, + /// The graph now holds the neighbouring state, and this is what it is + /// still owed. + Took(FilmRebake), +} + +impl Step { + /// Whether the graph moved. + /// + /// The panel has to be rebuilt when it did: undo replaces the values the + /// controls are showing and nothing here pushes them. + pub fn moved(&self) -> bool { + matches!(self, Self::Took(_)) + } + + /// The film this step needs baked back, if any. + pub fn rebake(&self) -> Option<&crate::state::FilmRef> { + match self { + Self::Nowhere => None, + Self::Took(film) => film.wanted(), + } + } +} + /// TRACES: FR-DEV-5 /// One image's undo stack. /// @@ -129,7 +194,7 @@ impl Edit { /// where redo goes. `states` is never empty — the state the history was opened /// on is the floor, and undo stops there rather than at nothing. pub struct History { - states: Vec, + states: Vec, cursor: usize, /// What produced `states[cursor]`, and when — the pair that decides /// whether the next change amends it. `None` means the current step is @@ -137,51 +202,11 @@ pub struct History { last: Option<(Edit, Instant)>, } -/// TRACES: FR-DEV-5 | FR-DEV-8 -/// One remembered state of an edit. -/// -/// # Why this is not simply a `Preset` -/// -/// It was, and that was right while every edit in the graph was a parameter. -/// A [`Preset`] is a map of scalars, which is exactly what the module -/// documentation above says a snapshot should be — nothing to register, nothing -/// to fall behind the operation set. -/// -/// A repair is not a scalar (see [`crate::spot`]), and undo is the single most -/// expected thing to do with one: place a spot, dislike it, take it back. If -/// the snapshot could not carry the spot set, that press would step some -/// unrelated slider instead and leave the repair on the photograph — which is -/// worse than no undo at all, because it looks like undo is broken rather than -/// absent. -/// -/// So the state is the pair. The same door is what the mask stack will come -/// through — masks are outside undo today for precisely this reason, and -/// FR-DEV-5 is not finished until they are not. -#[derive(Debug, Clone, PartialEq)] -struct State { - params: Preset, - spots: crate::spot::SpotSet, -} - -impl State { - fn capture(graph: &EditGraph) -> Self { - Self { - params: Preset::capture(graph), - spots: graph.spots().clone(), - } - } - - fn apply(&self, graph: &mut EditGraph) { - self.params.apply(graph, Scope::Everything); - *graph.spots_mut() = self.spots.clone(); - } -} - impl History { /// Start from the state `graph` is in. pub fn new(graph: &EditGraph) -> Self { Self { - states: vec![State::capture(graph)], + states: vec![graph.state()], cursor: 0, last: None, } @@ -208,7 +233,7 @@ impl History { /// [`Self::record`] with the clock supplied, so coalescing can be tested /// without sleeping. pub fn record_at(&mut self, graph: &EditGraph, edit: Edit, at: Instant) -> bool { - let state = State::capture(graph); + let state = graph.state(); if self.states.get(self.cursor) == Some(&state) { return false; } @@ -262,36 +287,37 @@ impl History { self.cursor + 1 < self.states.len() } - /// Step `graph` back one, returning whether there was anywhere to go. + /// Step `graph` back one. /// - /// Applied at [`Scope::Everything`]: framing is as undoable as colour, and - /// a crop drag the user wants back is one of the likelier reasons to - /// reach for this. The view survives, because [`Preset::apply`] preserves - /// it — an undo that also jumped the viewport would read as navigation. - pub fn undo(&mut self, graph: &mut EditGraph) -> bool { + /// The whole edit goes back, not merely its colour: framing is as undoable + /// as exposure, a mask as undoable as either, and a crop drag the user + /// wants back is one of the likelier reasons to reach for this. The view + /// survives, because [`EditGraph::set_state`] preserves it — an undo that + /// also jumped the viewport would read as navigation. + pub fn undo(&mut self, graph: &mut EditGraph) -> Step { let Some(target) = self.cursor.checked_sub(1) else { - return false; + return Step::Nowhere; }; self.restore(graph, target) } - /// Step `graph` forward one, returning whether there was anywhere to go. - pub fn redo(&mut self, graph: &mut EditGraph) -> bool { + /// Step `graph` forward one. + pub fn redo(&mut self, graph: &mut EditGraph) -> Step { self.restore(graph, self.cursor + 1) } - fn restore(&mut self, graph: &mut EditGraph, target: usize) -> bool { + fn restore(&mut self, graph: &mut EditGraph, target: usize) -> Step { let Some(state) = self.states.get(target).cloned() else { - return false; + return Step::Nowhere; }; - state.apply(graph); + let rebake = graph.set_state(&state); self.cursor = target; // Closes the current step. Without this, a slider moved immediately // after an undo would fold into the step it was just undone out of — // and the state the user had just recovered would be overwritten by // the very edit they made from it. self.last = None; - true + Step::Took(rebake) } /// How many states are held, the current one included. @@ -307,9 +333,48 @@ impl History { mod tests { use super::*; use crate::framing; + use crate::graph::Film; + use crate::mask::{MaskLayer, MaskSource}; use crate::ops::{curve, exposure, saturation}; + use crate::state::FilmRef; use crate::CropRect; + /// A radial layer, the cheapest thing a photographer can put on a picture + /// that is not a number. + fn layer(id: &str) -> MaskLayer { + MaskLayer::new( + id, + MaskSource::Radial { + centre: (0.5, 0.5), + radii: (0.25, 0.25), + angle: 0.0, + feather: 0.2, + }, + ) + } + + /// A stock with tables well-formed enough for the film node to keep them. + /// The numbers are not a real emulsion and do not need to be — what is + /// under test is whether the *choice* survives a step. + fn film(stock: &str) -> Film { + Film { + stock: stock.to_string(), + print: None, + 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, + }, + } + } + fn at(base: Instant, ms: u64) -> Instant { base + Duration::from_millis(ms) } @@ -328,6 +393,125 @@ mod tests { } } + #[test] + fn a_mask_the_user_just_drew_can_be_taken_back() { + // The bug this module was rewritten for. A snapshot used to be a + // `Preset` — the parameter map — and a mask layer is deliberately not + // a parameter, so drawing one changed nothing the history could see: + // `record` returned `false`, no step opened, and the layer had no way + // back. Nothing failed; the undo simply was not there. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + + g.masks_mut().push(layer("l1")); + assert!( + h.record(&g, Edit::Discrete), + "drawing a mask has to open a step" + ); + + assert!(h.undo(&mut g).moved()); + assert!( + g.masks().is_empty(), + "the layer survived an undo: {} left", + g.masks().len() + ); + assert!(h.redo(&mut g).moved()); + assert_eq!(g.masks().len(), 1, "and redo has to put it back"); + } + + #[test] + fn editing_a_layer_is_a_step_of_its_own() { + // Not merely adding and removing: the settings *inside* a layer are + // the part a photographer works at, and they are as far from being a + // graph parameter as the layer itself. + let mut g = EditGraph::default_chain(); + g.masks_mut().push(layer("l1")); + let mut h = History::new(&g); + + if let Some(l) = g.masks_mut().get_mut("l1") { + l.opacity = 0.4; + } + assert!(h.record(&g, Edit::Op(OpId("mask-opacity")))); + + assert!(h.undo(&mut g).moved()); + let back = g.masks().get("l1").expect("the layer itself must remain"); + assert!( + (back.opacity - 1.0).abs() < 1e-6, + "the opacity did not come back: {}", + back.opacity + ); + } + + #[test] + fn undoing_across_a_change_of_film_asks_for_the_stock_back() { + // A stock is not a scalar either, so it went missing the same way. It + // needs the extra half-step because this crate cannot bake tables — + // the step reports what it owes rather than leaving the caller to + // remember, which `Version::update` is the standing evidence for. + let mut g = EditGraph::default_chain(); + g.set_film(Some(film("kodak_portra_400"))); + let mut h = History::new(&g); + + g.set_film(Some(film("ilford_hp5"))); + assert!(h.record(&g, Edit::Discrete), "a change of stock is a step"); + + let step = h.undo(&mut g); + assert!(step.moved()); + assert_eq!( + step.rebake(), + Some(&FilmRef { + stock: "kodak_portra_400".into(), + print: None, + }), + "undo must name the stock it needs baked back" + ); + } + + #[test] + fn clearing_the_film_is_undoable_and_the_floor_has_none_to_restore() { + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + + g.set_film(Some(film("kodak_portra_400"))); + assert!(h.record(&g, Edit::Discrete)); + + let step = h.undo(&mut g); + assert!(step.moved()); + assert_eq!( + step.rebake(), + None, + "there was no film to go back to, so nothing is owed" + ); + assert!(g.film().is_none()); + } + + #[test] + fn a_step_that_leaves_the_masks_alone_does_not_copy_them() { + // The reason `EditState` shares its stack rather than owning it. + // `record` runs on every parameter change, which during a drag is once + // a frame; a painted brush is thousands of stroke points, and + // deep-copying it sixty times a second to note an exposure move would + // be a cost paid for nothing at all. + let mut g = EditGraph::default_chain(); + g.masks_mut().push(layer("l1")); + + let before = g.state(); + g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); + let after = g.state(); + + assert!( + std::sync::Arc::ptr_eq(&before.masks, &after.masks), + "an exposure move deep-copied the mask stack" + ); + + // And the sharing ends the moment a layer is actually touched, or the + // two snapshots would be the same object and undo would restore + // nothing. + g.masks_mut().push(layer("l2")); + assert!(!std::sync::Arc::ptr_eq(&before.masks, &g.state().masks)); + assert_eq!(before.masks.len(), 1, "the older snapshot was mutated"); + } + #[test] fn a_whole_drag_of_one_slider_is_a_single_undo_step() { // The reason this type is not a plain stack. A drag emits an event per @@ -340,7 +524,7 @@ mod tests { drag(&mut h, &mut g, base, 0.0, 1.5, 40); assert_eq!(h.depth(), 2, "the drag should have added exactly one state"); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); assert!(!h.can_undo(), "undo went behind the state it opened in"); } @@ -366,7 +550,7 @@ mod tests { ); assert_eq!(h.depth(), 3); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0)); } @@ -389,7 +573,7 @@ mod tests { at(base, 16), ); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!(g.param(saturation::ID, saturation::SATURATION), Some(0.0)); assert_eq!( g.param(exposure::ID, exposure::EXPOSURE), @@ -446,7 +630,7 @@ mod tests { } assert_eq!(h.depth(), 2); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert!( g.crop().is_full(), "the frame did not come back: {:?}", @@ -502,11 +686,11 @@ mod tests { g.set_param(saturation::ID, saturation::SATURATION, -40.0); g.set_param(framing::ID, framing::ANGLE, 3.0); h.record(&g, Edit::Discrete); - let after = Preset::capture(&g); + let after = g.state(); - assert!(h.undo(&mut g)); - assert!(h.redo(&mut g)); - assert_eq!(Preset::capture(&g), after); + assert!(h.undo(&mut g).moved()); + assert!(h.redo(&mut g).moved()); + assert_eq!(g.state(), after); assert!(!h.can_redo()); } @@ -521,7 +705,7 @@ mod tests { g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); h.record(&g, Edit::Discrete); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); g.set_param(saturation::ID, saturation::SATURATION, 25.0); h.record(&g, Edit::Discrete); @@ -549,7 +733,7 @@ mod tests { at(base, 2000), ); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0)); g.set_param(exposure::ID, exposure::EXPOSURE, 1.1); @@ -559,7 +743,7 @@ mod tests { at(base, 2010), ); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!( g.param(exposure::ID, exposure::EXPOSURE), Some(1.0), @@ -586,7 +770,7 @@ mod tests { // Every step still present is reachable, and the walk stops at the // floor rather than running off the end. let mut steps = 0; - while h.undo(&mut g) { + while h.undo(&mut g).moved() { steps += 1; } assert_eq!(steps, DEPTH - 1); @@ -627,7 +811,7 @@ mod tests { width: 0.25, height: 0.25, }); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert!(g.framing().is_zoomed(), "the undo threw away the viewport"); } diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 431fc3e..eef87d5 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -54,7 +54,7 @@ pub use detail::{ }; pub use framing::{CropRect, Framing}; pub use graph::{EditGraph, OpCapability, ParamCapability}; -pub use history::{Edit, History}; +pub use history::{Edit, History, Step}; pub use lens::{compose_warps, ComposedWarp, Warp}; pub use operation::{ compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, diff --git a/core/dr-pipeline/tests/spots.rs b/core/dr-pipeline/tests/spots.rs index 5bc61c8..ec6b392 100644 --- a/core/dr-pipeline/tests/spots.rs +++ b/core/dr-pipeline/tests/spots.rs @@ -259,10 +259,10 @@ fn undo_takes_a_repair_back() { assert!(history.record(&graph, Edit::Discrete)); assert_eq!(graph.spots().len(), 1); - assert!(history.undo(&mut graph)); + assert!(history.undo(&mut graph).moved()); assert_eq!(graph.spots().len(), 0, "the repair is still on the frame"); - assert!(history.redo(&mut graph)); + assert!(history.redo(&mut graph).moved()); assert_eq!(graph.spots().len(), 1, "and redo could not put it back"); } @@ -287,7 +287,7 @@ fn undo_steps_back_through_a_moved_source() { .set_offset((0.2, 0.1)); history.record(&graph, Edit::Discrete); - assert!(history.undo(&mut graph)); + assert!(history.undo(&mut graph).moved()); assert_eq!( graph.spots().get(&id).map(|s| s.offset), Some((0.08, 0.0)), diff --git a/docs/traceability.md b/docs/traceability.md index 2b61bf5..36a0b10 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -10,7 +10,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| | Source files scanned | 228 | -| TRACES tags found | 629 | +| TRACES tags found | 631 | | Requirements defined | 177 | | Requirements covered | 91 | | **Coverage** | **51.4%** (91/177) | @@ -52,21 +52,21 @@ _None._ | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:134`](../core/dr-pipeline/src/sidecar.rs#L134), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:158`](../core/dr-pipeline/src/graph.rs#L158), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1431`](../core/dr-pipeline/src/sidecar.rs#L1431), [`core/dr-pipeline/src/sidecar.rs:1491`](../core/dr-pipeline/src/sidecar.rs#L1491), [`core/dr-pipeline/src/sidecar.rs:155`](../core/dr-pipeline/src/sidecar.rs#L155), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2762`](../ui/dr-ui/src/develop.rs#L2762), [`ui/dr-ui/src/develop.rs:3214`](../ui/dr-ui/src/develop.rs#L3214), [`ui/dr-ui/src/develop.rs:3268`](../ui/dr-ui/src/develop.rs#L3268), [`ui/dr-ui/src/develop.rs:3312`](../ui/dr-ui/src/develop.rs#L3312), [`ui/dr-ui/src/develop.rs:3362`](../ui/dr-ui/src/develop.rs#L3362), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1275`](../ui/dr-ui/src/lib.rs#L1275), [`ui/dr-ui/src/lib.rs:1934`](../ui/dr-ui/src/lib.rs#L1934), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/app.slint:913`](../ui/dr-ui/ui/app.slint#L913) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:158`](../core/dr-pipeline/src/graph.rs#L158), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1431`](../core/dr-pipeline/src/sidecar.rs#L1431), [`core/dr-pipeline/src/sidecar.rs:1491`](../core/dr-pipeline/src/sidecar.rs#L1491), [`core/dr-pipeline/src/sidecar.rs:155`](../core/dr-pipeline/src/sidecar.rs#L155), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2762`](../ui/dr-ui/src/develop.rs#L2762), [`ui/dr-ui/src/develop.rs:3237`](../ui/dr-ui/src/develop.rs#L3237), [`ui/dr-ui/src/develop.rs:3291`](../ui/dr-ui/src/develop.rs#L3291), [`ui/dr-ui/src/develop.rs:3335`](../ui/dr-ui/src/develop.rs#L3335), [`ui/dr-ui/src/develop.rs:3385`](../ui/dr-ui/src/develop.rs#L3385), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1275`](../ui/dr-ui/src/lib.rs#L1275), [`ui/dr-ui/src/lib.rs:1934`](../ui/dr-ui/src/lib.rs#L1934), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/app.slint:913`](../ui/dr-ui/ui/app.slint#L913) | | FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:229`](../core/dr-pipeline/src/graph.rs#L229), [`core/dr-pipeline/src/graph.rs:22`](../core/dr-pipeline/src/graph.rs#L22), [`core/dr-pipeline/src/graph.rs:44`](../core/dr-pipeline/src/graph.rs#L44), [`core/dr-pipeline/src/graph.rs:57`](../core/dr-pipeline/src/graph.rs#L57), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:579`](../ui/dr-ui/src/lib.rs#L579) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:57`](../core/dr-pipeline/src/graph.rs#L57), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:229`](../core/dr-pipeline/src/graph.rs#L229), [`core/dr-pipeline/src/graph.rs:44`](../core/dr-pipeline/src/graph.rs#L44), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3941`](../ui/dr-ui/src/develop.rs#L3941) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:229`](../core/dr-pipeline/src/graph.rs#L229), [`core/dr-pipeline/src/graph.rs:44`](../core/dr-pipeline/src/graph.rs#L44), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3964`](../ui/dr-ui/src/develop.rs#L3964) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:433`](../core/dr-gpu/tests/capture_sharpen.rs#L433), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:576`](../core/dr-pipeline/src/graph.rs#L576), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:1491`](../core/dr-pipeline/src/operation.rs#L1491), [`core/dr-pipeline/src/operation.rs:1516`](../core/dr-pipeline/src/operation.rs#L1516), [`core/dr-pipeline/src/operation.rs:1531`](../core/dr-pipeline/src/operation.rs#L1531), [`core/dr-pipeline/src/operation.rs:1555`](../core/dr-pipeline/src/operation.rs#L1555), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:413`](../core/dr-pipeline/src/operation.rs#L413), [`core/dr-pipeline/src/operation.rs:549`](../core/dr-pipeline/src/operation.rs#L549) | | FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:88`](../core/dr-film/src/grain.rs#L88), [`core/dr-film/src/lib.rs:158`](../core/dr-film/src/lib.rs#L158), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:100`](../core/dr-pipeline/src/graph.rs#L100), [`core/dr-pipeline/src/graph.rs:114`](../core/dr-pipeline/src/graph.rs#L114), [`core/dr-pipeline/src/graph.rs:303`](../core/dr-pipeline/src/graph.rs#L303), [`core/dr-pipeline/src/operation.rs:1014`](../core/dr-pipeline/src/operation.rs#L1014), [`core/dr-pipeline/src/operation.rs:1043`](../core/dr-pipeline/src/operation.rs#L1043), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:126`](../core/dr-pipeline/src/ops/film_sim.rs#L126), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:300`](../core/dr-pipeline/src/ops/film_sim.rs#L300), [`core/dr-pipeline/src/ops/film_sim.rs:68`](../core/dr-pipeline/src/ops/film_sim.rs#L68), [`core/dr-pipeline/src/sidecar.rs:110`](../core/dr-pipeline/src/sidecar.rs#L110), [`core/dr-pipeline/src/sidecar.rs:166`](../core/dr-pipeline/src/sidecar.rs#L166), [`core/dr-pipeline/src/sidecar.rs:1752`](../core/dr-pipeline/src/sidecar.rs#L1752), [`core/dr-pipeline/src/sidecar.rs:1828`](../core/dr-pipeline/src/sidecar.rs#L1828), [`core/dr-pipeline/src/sidecar.rs:434`](../core/dr-pipeline/src/sidecar.rs#L434), [`core/dr-pipeline/src/sidecar.rs:560`](../core/dr-pipeline/src/sidecar.rs#L560), [`core/dr-pipeline/src/sidecar.rs:683`](../core/dr-pipeline/src/sidecar.rs#L683), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:55`](../core/dr-pipeline/src/state.rs#L55), [`core/dr-pipeline/src/state.rs:95`](../core/dr-pipeline/src/state.rs#L95), [`ui/dr-ui/src/develop.rs:2340`](../ui/dr-ui/src/develop.rs#L2340), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/develop.rs:2388`](../ui/dr-ui/src/develop.rs#L2388), [`ui/dr-ui/src/develop.rs:2485`](../ui/dr-ui/src/develop.rs#L2485), [`ui/dr-ui/src/develop.rs:2798`](../ui/dr-ui/src/develop.rs#L2798), [`ui/dr-ui/src/develop.rs:2813`](../ui/dr-ui/src/develop.rs#L2813), [`ui/dr-ui/src/lib.rs:1908`](../ui/dr-ui/src/lib.rs#L1908), [`ui/dr-ui/src/lib.rs:512`](../ui/dr-ui/src/lib.rs#L512), [`ui/dr-ui/src/lib.rs:570`](../ui/dr-ui/src/lib.rs#L570), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:879`](../ui/dr-ui/ui/adjust.slint#L879), [`ui/dr-ui/ui/adjust.slint:949`](../ui/dr-ui/ui/adjust.slint#L949), [`ui/dr-ui/ui/app.slint:2370`](../ui/dr-ui/ui/app.slint#L2370), [`ui/dr-ui/ui/app.slint:707`](../ui/dr-ui/ui/app.slint#L707) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/graph.rs:361`](../core/dr-pipeline/src/graph.rs#L361), [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:70`](../core/dr-pipeline/src/state.rs#L70), [`ui/dr-ui/src/develop.rs:2837`](../ui/dr-ui/src/develop.rs#L2837), [`ui/dr-ui/src/develop.rs:2847`](../ui/dr-ui/src/develop.rs#L2847), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1267`](../ui/dr-ui/src/lib.rs#L1267) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/graph.rs:361`](../core/dr-pipeline/src/graph.rs#L361), [`core/dr-pipeline/src/history.rs:141`](../core/dr-pipeline/src/history.rs#L141), [`core/dr-pipeline/src/history.rs:177`](../core/dr-pipeline/src/history.rs#L177), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:72`](../core/dr-pipeline/src/history.rs#L72), [`core/dr-pipeline/src/history.rs:88`](../core/dr-pipeline/src/history.rs#L88), [`core/dr-pipeline/src/history.rs:96`](../core/dr-pipeline/src/history.rs#L96), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:70`](../core/dr-pipeline/src/state.rs#L70), [`ui/dr-ui/src/develop.rs:2813`](../ui/dr-ui/src/develop.rs#L2813), [`ui/dr-ui/src/develop.rs:2843`](../ui/dr-ui/src/develop.rs#L2843), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:2868`](../ui/dr-ui/src/develop.rs#L2868), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1267`](../ui/dr-ui/src/lib.rs#L1267) | | FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:2756`](../ui/dr-ui/src/develop.rs#L2756), [`ui/dr-ui/src/develop.rs:2777`](../ui/dr-ui/src/develop.rs#L2777), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:535`](../ui/dr-ui/src/settings_ui.rs#L535), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1305`](../ui/dr-ui/ui/library.slint#L1305), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:912`](../ui/dr-ui/ui/library.slint#L912), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | | FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2090`](../core/dr-gpu/src/adjust.rs#L2090), [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:2252`](../core/dr-gpu/src/adjust.rs#L2252), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:517`](../core/dr-pipeline/src/graph.rs#L517), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:3003`](../ui/dr-ui/src/develop.rs#L3003), [`ui/dr-ui/src/develop.rs:3486`](../ui/dr-ui/src/develop.rs#L3486), [`ui/dr-ui/src/develop.rs:3520`](../ui/dr-ui/src/develop.rs#L3520), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2090`](../core/dr-gpu/src/adjust.rs#L2090), [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:2252`](../core/dr-gpu/src/adjust.rs#L2252), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:517`](../core/dr-pipeline/src/graph.rs#L517), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:3026`](../ui/dr-ui/src/develop.rs#L3026), [`ui/dr-ui/src/develop.rs:3509`](../ui/dr-ui/src/develop.rs#L3509), [`ui/dr-ui/src/develop.rs:3543`](../ui/dr-ui/src/develop.rs#L3543), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4588`](../ui/dr-ui/src/develop.rs#L4588), [`ui/dr-ui/src/develop.rs:4620`](../ui/dr-ui/src/develop.rs#L4620), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1326`](../ui/dr-ui/src/lib.rs#L1326), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4611`](../ui/dr-ui/src/develop.rs#L4611), [`ui/dr-ui/src/develop.rs:4643`](../ui/dr-ui/src/develop.rs#L4643), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1326`](../ui/dr-ui/src/lib.rs#L1326), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2400`](../core/dr-gpu/src/adjust.rs#L2400), [`core/dr-pipeline/src/graph.rs:507`](../core/dr-pipeline/src/graph.rs#L507), [`core/dr-pipeline/src/graph.rs:560`](../core/dr-pipeline/src/graph.rs#L560), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | @@ -119,7 +119,7 @@ _None._ | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) | +| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:72`](../core/dr-pipeline/src/history.rs#L72), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:394`](../core/dr-catalog/src/schema.rs#L394), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) | diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 725a760..a4ba8ba 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -3163,7 +3163,7 @@ impl DevelopSession { self.history.reset(&self.graph); } - /// TRACES: FR-DEV-3f + /// TRACES: FR-DEV-3f | FR-DEV-5 /// Pay what a restored edit owes the picture. /// /// `dr-pipeline` restores a stock's *name* and clears its tables, because @@ -3176,6 +3176,12 @@ impl DevelopSession { /// on holding textures that nothing will sample. That is the two halves /// disagreeing, which is the failure `set_film` exists to make /// impossible — and it is silent in this direction, which is worse. + /// + /// Baked unconditionally rather than only when the stock changed: the + /// tables come from the film node's own exposure sliders as well as from + /// the stock, and restoring an edit replaces those sliders too. A bake is + /// milliseconds and this happens on a keypress, so the cheap correct rule + /// beats the clever one. fn pay_film_debt(&mut self, rebake: &dr_pipeline::FilmRebake) { match rebake.wanted() { Some(film) => { @@ -3187,6 +3193,19 @@ impl DevelopSession { } } + /// TRACES: FR-DEV-5 + /// [`Self::pay_film_debt`] for a history step, when the step went + /// anywhere. + /// + /// The guard is the whole difference between the two: a step that found + /// nowhere to go left the graph alone, and clearing the film because + /// undo hit the floor would take the picture's stock off it. + fn settle(&mut self, step: &dr_pipeline::Step) { + if let dr_pipeline::Step::Took(rebake) = step { + self.pay_film_debt(rebake); + } + } + /// TRACES: FR-DEV-5 /// Step the edit back one, returning whether anything moved. /// @@ -3194,13 +3213,17 @@ impl DevelopSession { /// reason a paste must: this moves values the controls are showing and /// nothing here pushes them. pub fn undo(&mut self) -> bool { - self.history.undo(&mut self.graph) + let step = self.history.undo(&mut self.graph); + self.settle(&step); + step.moved() } /// TRACES: FR-DEV-5 /// Step the edit forward one, returning whether anything moved. pub fn redo(&mut self) -> bool { - self.history.redo(&mut self.graph) + let step = self.history.redo(&mut self.graph); + self.settle(&step); + step.moved() } pub fn can_undo(&self) -> bool {