//! The edit graph — an ordered set of operations (ARCH §3.4). //! //! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any //! moment on Android (ARCH §6.10), and recovery is only tractable because //! everything needed to re-render lives here rather than in GPU memory. //! //! Order is data, not code: operations run in the sequence this holds them, //! so reordering the pipeline needs no code change. use crate::descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation, }; use crate::framing::{CropRect, Framing}; use crate::mask::MaskStack; use crate::operation::{compose_full, ComposedShader, Operation}; use crate::ops; /// TRACES: FR-DEV-3a /// What one operation offers, as plain data. /// /// Deliberately owned rather than borrowed, and free of any trait objects: /// the UI receives a snapshot it can hold across a frame without borrowing /// the graph, and nothing in it hints at how the operation is implemented. #[derive(Debug, Clone, PartialEq)] pub struct OpCapability { pub id: OpId, /// A key for the UI's own catalogue. Never a display string — resolving /// it needs a localiser, which `core/` must not depend on. pub label: LocalizedKey, /// Whether this operation currently alters the image. A UI may use it to /// mark a section as modified, or to offer a per-operation reset. pub active: bool, pub params: Vec, /// A hint that several of `params` form one conceptual control. /// /// `None` means one control per parameter. A UI that does not implement /// the named widget may ignore this and render sliders — the parameters /// are ordinary scalars either way, so nothing becomes unreachable. pub presentation: Option, /// TRACES: FR-DEV-3a | FR-DEV-3c /// What this operation is about. /// /// **The whole point is that a panel can group by these without knowing /// what any operation is.** A tab strip built from the attributes present /// in this list names no operation and needs no table mapping one to the /// other, so a new operation joins the right group by declaring what it /// is — which is the only thing its author is well placed to say. /// /// Never empty; `build.rs` refuses an operation that declares none. pub attributes: &'static [Attribute], } /// TRACES: FR-DEV-3a | FR-DEV-3b /// What one parameter offers. /// /// [`Self::kind`] is what selects the control: the UI maps each `ParamKind` /// to a widget appropriate to the current input modality (ARCH §4.3), and /// never switches on the parameter's identity. #[derive(Debug, Clone, PartialEq)] pub struct ParamCapability { pub id: ParamId, pub label: LocalizedKey, pub kind: ParamKind, pub default: f32, /// The current setting, so the control opens where the edit actually is. pub value: f32, /// Where this parameter sits among its siblings, when the operation's /// parameters form a grid rather than a list. `None` for the usual case. pub facet: Option, } impl ParamCapability { /// Whether this parameter is away from its default. pub fn is_modified(&self) -> bool { self.value != self.default } } /// An ordered pipeline of operations, plus how the result is framed. pub struct EditGraph { ops: Vec>, /// Crop, straighten, rotation and flips. /// /// Held apart from `ops` rather than in the list because it is not one: /// an operation transforms a colour, and framing decides which source /// pixel that colour is read from — and changes the output's dimensions, /// which no colour operation can do. See [`crate::framing`]. framing: Framing, /// The local adjustments (FR-DEV-3). /// /// Also apart from `ops`, and for a sharper reason than framing's: each /// layer *contains* a chain of its own. Folding the stack into the global /// list would make the list recursive and every consumer that walks it /// have to know that some entries are really sub-graphs. masks: MaskStack, } impl EditGraph { /// The default develop chain, in pipeline order (ARCH §5.2). /// /// The order is not written here. Each node declares its own place with /// an `order:` in `ops/.yaml`, and [`ops::chain`] is generated from /// those — so adding an operation, or moving one, is an edit to a /// declaration rather than to this file. /// /// The order itself is still not arbitrary. White balance and exposure /// come first because they are corrections to how the scene was captured, /// and the tonal operations that follow should act on a correctly exposed /// image. Colour comes last, so vibrance responds to the tones the user /// has actually settled on rather than the ones they started with. Each /// node records that reasoning for itself, under `placement:`. pub fn default_chain() -> Self { Self { ops: ops::chain(), framing: Framing::new(), masks: MaskStack::new(), } } /// TRACES: FR-DEV-3 /// The default chain with the detail stage's test consumer appended. /// /// **Not a shipping path.** `detail_probe` is a separable box blur that /// exists so the neighbourhood stage has something to run (see /// [`crate::detail::probe`]); it is not declared in `ops/`, has no place /// in the pipeline order, and is compiled only for tests and behind the /// `detail-probe` feature. /// /// It is a constructor rather than a fixture inside one test module /// because `dr-gpu` needs the same graph: proving the stage works means /// dispatching it, and dispatching it means composing both halves of the /// shader from one graph exactly as the interface will. #[cfg(any(test, feature = "detail-probe"))] pub fn with_detail_probe() -> Self { let mut graph = Self::default_chain(); graph .ops .push(Box::new(crate::detail::probe::BoxBlur::new())); graph } /// The local adjustment stack. pub fn masks(&self) -> &MaskStack { &self.masks } pub fn masks_mut(&mut self) -> &mut MaskStack { &mut self.masks } /// The framing — crop, straighten, rotation and flips. /// /// Reached directly rather than through `set_param` because the crop is a /// rectangle, and driving one through four independent scalars makes an /// interactive drag four clamps that can disagree. The parameter route /// still exists for the sidecar, which has only scalars to work with. pub fn framing(&self) -> &Framing { &self.framing } pub fn framing_mut(&mut self) -> &mut Framing { &mut self.framing } /// The size this graph renders to, given a source of `(w, h)`. /// /// Cropping and quarter turns change it, so the caller allocating the /// output texture must ask rather than assume the source size. pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) { self.framing.output_size(width, height) } /// Descriptors for every operation, in order. /// /// Operations only — framing is not one, and is reached through /// [`Self::framing`] or the capability list. The distinction matters here /// because this is what the codegen tests count `---- ` shader blocks /// against, and framing generates a prologue rather than a colour block. /// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a). pub fn descriptors(&self) -> Vec<&'static OpDescriptor> { self.ops.iter().map(|o| o.descriptor()).collect() } /// TRACES: FR-DEV-3a | FR-DEV-3c /// Everything a UI needs to build its controls. /// /// **This is the only thing the UI should read.** It must not know that /// exposure exists, that saturation is implemented with a mix, or that /// any of this becomes a shader — it walks this list and instantiates a /// control per entry according to the [`ParamKind`]. A new operation /// therefore appears in the interface with no UI change at all /// (FR-DEV-3c), and an operation removed from the chain disappears from /// it just as automatically. /// /// Current values are included so the UI has no separate initialisation /// step, and so reopening an edited image shows where the sliders /// actually are. pub fn capabilities(&self) -> Vec { let ops = self.ops.iter().map(|op| { let desc = op.descriptor(); OpCapability { id: desc.id, label: desc.label, active: op.is_active(), params: desc .params .iter() .map(|p| ParamCapability { id: p.id, label: p.label, kind: p.kind.clone(), default: p.default, value: op.param(p.id), facet: p.facet, }) .collect(), presentation: op.presentation(), attributes: desc.attributes, } }); // Framing last, matching where it sits in the pipeline: the crop is // decided after the image looks right, not before. let desc = self.framing.descriptor(); let framing = OpCapability { id: desc.id, label: desc.label, active: self.framing.is_active(), params: desc .params .iter() .map(|p| ParamCapability { id: p.id, label: p.label, kind: p.kind.clone(), default: p.default, value: self.framing.param(p.id), facet: p.facet, }) .collect(), // Framing is not an `Operation`, but it has the same thing to say // about how it wants drawing: a crop is dragged on the photograph. // Declaring it here is what lets the frontend skip generating // sliders for framing *without naming framing* — see // `Framing::presentation`. presentation: self.framing.presentation(), attributes: desc.attributes, }; ops.chain(std::iter::once(framing)).collect() } /// Set a parameter, clamping to the descriptor's declared range. /// /// Clamping here rather than in each operation means an operation never /// has to defend against an out-of-range value, and a corrupt sidecar /// cannot reach a shader. pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) { if op == crate::framing::ID { let Some(desc) = self.framing.descriptor().param(param) else { log::warn!("unknown parameter {param} on {op}; ignoring"); return; }; self.framing.set_param(param, desc.clamp(value)); return; } let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else { // A sidecar naming an operation this build does not have. The // rest of the edit must still apply. log::warn!("unknown operation {op}; ignoring"); return; }; let clamped = match operation.descriptor().param(param) { Some(d) => d.clamp(value), None => { log::warn!("unknown parameter {param} on {op}; ignoring"); return; } }; operation.set_param(param, clamped); } /// Read a parameter back. pub fn param(&self, op: OpId, param: ParamId) -> Option { if op == crate::framing::ID { return self .framing .descriptor() .param(param) .map(|_| self.framing.param(param)); } self.ops .iter() .find(|o| o.descriptor().id == op) .map(|o| o.param(param)) } /// Reset every parameter of every operation, and the framing, to default. pub fn reset(&mut self) { for op in &mut self.ops { for p in op.descriptor().params { op.set_param(p.id, p.default); } } self.framing.reset(); // Masks go too, and this is why `apply` can be a replacement rather // than an overlay: a sidecar with no mask blocks means an edit with no // local adjustments, not an edit that keeps whatever was on screen. self.masks = MaskStack::new(); } /// Set the crop rectangle. Clamped to keep it inside the frame. pub fn set_crop(&mut self, rect: CropRect) { self.framing.set_crop(rect); } pub fn crop(&self) -> CropRect { self.framing.crop() } /// Rotate by quarter turns, wrapping. The rotate-left/right buttons. pub fn rotate_quarters(&mut self, turns: i32) { self.framing.rotate_quarters(turns); } /// Record how the file stored its pixels, from its EXIF orientation. /// /// Set when the image is opened and never by an edit — see /// [`crate::framing::Framing::set_baseline`]. Survives [`Self::reset`], /// so it is safe to call before restoring a sidecar. pub fn set_orientation(&mut self, orientation: dr_types::Orientation) { self.framing.set_baseline(orientation); } /// Whether any operation, or the framing, currently changes the image. /// /// Asks the framing whether it *edits*, not whether it is active: zoom /// makes the framing active without changing the image, and reporting a /// merely-zoomed image as edited would mark a clean file dirty. pub fn is_neutral(&self) -> bool { !self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image() && self.masks.is_neutral() } /// Generate the fused shader for the current state, encoded to sRGB. /// /// What the display path wants. An export that has been asked for a wider /// space wants [`Self::compose_for`] instead, and must say so: the space /// is baked into the shader, so a frame rendered by this one is sRGB and /// nothing downstream can make it anything else. pub fn compose(&self) -> ComposedShader { self.compose_for(dr_types::ColourSpace::Srgb) } /// TRACES: FR-EXP-2 /// Generate the fused shader, encoded to a chosen output space. /// /// Not stored on the graph, because it is not part of the edit: the same /// graph renders to the screen and to a file in the same breath, and the /// two want different answers. pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader { compose_full(&self.ops, &self.framing, output, &self.masks) } /// TRACES: FR-DSP-1 /// How this render relates to the file it stands for. /// /// `source` is the demosaiced image's size and `render` the size being /// drawn now. The result describes *the region on screen*, with the crop /// and the zoom already folded in: cropping to half the frame while the /// viewport stays the same size genuinely does show twice the detail, and /// zooming to 1:1 genuinely does make the preview exact. Both fall out of /// the arithmetic rather than needing a special case. /// /// Only the detail stage needs this. Every point operation is scale-free /// — a multiply is a multiply at any resolution — which is why nothing in /// the pipeline had to know its own size until a kernel arrived. pub fn render_scale(&self, source: (u32, u32), render: (u32, u32)) -> crate::detail::RenderScale { let (fw, fh) = self.framing.output_size(source.0, source.1); let view = self.framing.view(); // The *viewed* part of the framed image, at source resolution. Zoom // shrinks the view rect while the render target keeps its size, so // this is what shrinks and the ratio is what climbs. let full = ( ((fw as f32 * view.width).round() as u32).max(1), ((fh as f32 * view.height).round() as u32).max(1), ); crate::detail::RenderScale::new(render, full) } /// TRACES: FR-DEV-3 | FR-DSP-1 /// Generate the detail stage for this edit at one resolution, to sRGB. /// /// Empty for every edit with no active neighbourhood operation, which is /// almost all of them — and in that case [`Self::compose`] emits the /// single encoded dispatch it always has. pub fn compose_detail(&self, scale: crate::detail::RenderScale) -> crate::detail::ComposedDetail { self.compose_detail_for(scale, dr_types::ColourSpace::Srgb) } /// TRACES: FR-EXP-2 /// The detail stage, encoded into a chosen output space. /// /// The space belongs here as well as on [`Self::compose_for`] because when /// a detail stage exists it is the *last* pass that performs the output /// transform — the fused pass stops at linear working values. Composing /// the two halves for different spaces would encode the edit twice, or /// not at all. pub fn compose_detail_for( &self, scale: crate::detail::RenderScale, output: dr_types::ColourSpace, ) -> crate::detail::ComposedDetail { crate::detail::compose_detail(&self.ops, scale, output) } /// TRACES: FR-DEV-3d /// The per-stage cache keys for the current edit. /// /// See [`crate::Invalidation`] for what the keys mean and what may be /// cached against them. In short: geometry covers the framing, colour /// covers every fused operation and every mask layer, and detail covers /// the neighbourhood operations — so moving one slider moves exactly one /// key, and a consumer can tell which stages it has to redo. pub fn invalidation(&self) -> crate::Invalidation { use crate::operation::{hash_bytes, hash_op, mix, Affects, FNV_OFFSET}; // Geometry: the framing. Its own structure key covers the shape of the // coordinate map; the parameters cover the magnitudes, which the // structure key deliberately omits because they do not recompile a // shader. Both matter to a cached *result*, so both are here. let mut geometry = mix(FNV_OFFSET, self.framing.structure_key()); for p in self.framing.descriptor().params { geometry = hash_bytes(geometry, p.id.0.as_bytes()); geometry = mix( geometry, u64::from(crate::operation::canonical_bits(self.framing.param(p.id))), ); } // The view rect is not a parameter and not in the structure key — it // is not an edit (see `Framing::view`). It is still an input to every // rendered pixel, so a cache that ignored it would show the wrong part // of the photograph after a scroll. let view = self.framing.view(); for v in [view.x, view.y, view.width, view.height] { geometry = mix(geometry, u64::from(crate::operation::canonical_bits(v))); } let mut colour = FNV_OFFSET; let mut detail = FNV_OFFSET; for op in &self.ops { let target = if op.affects() == Affects::Detail { &mut detail } else { &mut colour }; *target = hash_op(*target, op.as_ref()); } // The mask layers belong to the colour stage: their chains are fused // into the same dispatch, and a layer's *shape* decides which pixels // that dispatch treats differently. Both halves are folded in. for layer in self.masks.layers() { colour = hash_bytes(colour, layer.id.as_bytes()); // The source through its `Debug`, deliberately. A gradient's // centre, a region's id list and a subject's signature are all // part of where the layer applies, and matching on the variants // here would be a second copy of `MaskSource`'s shape that falls // out of step the first time a variant gains a field — silently, // and showing as a mask that stops updating. `Debug` cannot fall // out of step, because it is derived from the definition itself. colour = hash_bytes(colour, format!("{:?}", layer.source).as_bytes()); colour = mix(colour, u64::from(layer.enabled)); colour = mix(colour, u64::from(layer.invert)); colour = hash_bytes(colour, layer.falloff.name().as_bytes()); for v in [layer.opacity, layer.feather, layer.morph_radius] { colour = mix(colour, u64::from(crate::operation::canonical_bits(v))); } for (op_id, param_id, value) in layer.params() { colour = hash_bytes(colour, op_id.as_bytes()); colour = hash_bytes(colour, param_id.as_bytes()); colour = mix(colour, u64::from(crate::operation::canonical_bits(value))); } } crate::Invalidation::new(geometry, colour, detail) } } impl Default for EditGraph { fn default() -> Self { Self::default_chain() } } #[cfg(test)] mod tests { use super::*; use crate::ops::{exposure, white_balance}; #[test] fn a_fresh_graph_is_neutral() { // Opening an unedited image must produce the image, not an // interpretation of it. let g = EditGraph::default_chain(); assert!(g.is_neutral()); assert_eq!( g.compose().source.matches("---- ").count(), 0, "a neutral graph must generate no operation blocks" ); } #[test] fn the_default_chain_exposes_every_operation() { let g = EditGraph::default_chain(); let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect(); for expected in [ "white_balance", "exposure", "highlights_shadows", "blacks_whites", "brilliance", "vibrance", "saturation", ] { assert!(ids.contains(&expected), "{expected} missing from the chain"); } } #[test] fn white_balance_and_exposure_precede_the_tonal_operations() { // Corrections to capture must come before interpretation of tone, or // the tonal controls act on a wrongly exposed image. let g = EditGraph::default_chain(); let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect(); let pos = |id: &str| ids.iter().position(|x| *x == id).expect(id); assert!(pos("white_balance") < pos("highlights_shadows")); assert!(pos("exposure") < pos("highlights_shadows")); assert!(pos("highlights_shadows") < pos("vibrance")); } #[test] fn setting_a_parameter_activates_its_operation() { let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.5); assert!(!g.is_neutral()); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.5)); assert!(g.compose().source.contains("---- exposure ----")); } #[test] fn values_are_clamped_to_the_descriptor() { // The guarantee that lets each operation skip range checks. let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 99.0); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0)); } #[test] fn an_unknown_operation_is_ignored_rather_than_panicking() { // A sidecar from a newer version names operations this build lacks. // The rest of the edit must still load. let mut g = EditGraph::default_chain(); g.set_param(OpId("time_machine"), ParamId("year"), 1994.0); assert!(g.is_neutral()); } #[test] fn an_unknown_parameter_is_ignored() { let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, ParamId("nonexistent"), 3.0); assert!(g.is_neutral()); } #[test] fn reset_returns_every_operation_to_neutral() { let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 2.0); g.set_param(white_balance::ID, white_balance::TEMPERATURE, 50.0); assert!(!g.is_neutral()); g.reset(); assert!(g.is_neutral(), "reset must clear every operation"); } #[test] fn only_active_operations_reach_the_shader() { // The composition property, end to end: two adjustments out of seven // available must generate a shader doing exactly two things. let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); g.set_param(white_balance::ID, white_balance::TINT, 25.0); let shader = g.compose(); assert_eq!(shader.source.matches("---- ").count(), 2); assert!(shader.source.contains("---- exposure ----")); assert!(shader.source.contains("---- white_balance ----")); assert!(!shader.source.contains("---- saturation ----")); } #[test] fn moving_a_slider_does_not_change_the_shader_structure() { // What makes the pipeline cache worth having: dragging a slider must // reuse the compiled pipeline and upload uniforms only. let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); let first = g.compose(); g.set_param(exposure::ID, exposure::EXPOSURE, 2.0); let second = g.compose(); assert_eq!(first.structure_hash, second.structure_hash); assert_eq!(first.source, second.source); assert_ne!(first.uniforms, second.uniforms); } #[test] fn capabilities_describe_every_operation_and_parameter() { // The UI builds its whole panel from this. Anything missing here is // something the UI would have to hardcode. let g = EditGraph::default_chain(); let caps = g.capabilities(); // Every operation, plus framing — which is not an operation and so // is absent from `descriptors`, but must still reach the panel. assert_eq!(caps.len(), g.descriptors().len() + 1); assert!( caps.iter().any(|c| c.id == crate::framing::ID), "framing must appear in the capability list, or the UI cannot \ build a crop control without naming it" ); for cap in &caps { assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id); for p in &cap.params { // A control cannot be built without a range. match &p.kind { ParamKind::Scalar { min, max, .. } => { assert!(min < max, "{}.{} has an empty range", cap.id, p.id); assert!( (*min..=*max).contains(&p.default), "{}.{} default is outside its range", cap.id, p.id ); } ParamKind::Bool => {} ParamKind::Enum { variants } => { // An empty list is a control with nothing to pick, and // a one-entry list is a control that cannot be // changed — both are declaration mistakes rather than // states a UI should try to render. assert!( variants.len() > 1, "{}.{} offers fewer than two choices", cap.id, p.id ); assert!( p.default >= 0.0 && p.default < variants.len() as f32, "{}.{} defaults to a variant that does not exist", cap.id, p.id ); } } } } } #[test] fn capabilities_report_current_values_not_just_defaults() { // So reopening an edited image shows the sliders where the edit left // them, with no separate initialisation path in the UI. let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.25); let cap = g .capabilities() .into_iter() .find(|c| c.id == exposure::ID) .expect("exposure is in the chain"); let p = &cap.params[0]; assert_eq!(p.value, 1.25); assert_eq!(p.default, 0.0); assert!(p.is_modified()); assert!(cap.active); } #[test] fn a_fresh_graph_reports_nothing_modified() { for cap in EditGraph::default_chain().capabilities() { assert!(!cap.active, "{} should start inactive", cap.id); for p in &cap.params { assert!( !p.is_modified(), "{}.{} should start at default", cap.id, p.id ); } } } #[test] fn capabilities_survive_a_round_trip_through_set_param() { // The UI reads a capability, writes the value back, and must get the // same thing out — no hidden scaling between the two. // // Written at the parameter's declared precision, because that is what // the UI can actually produce: a control declaring 0 decimals emits // whole numbers, and a stage free to quantise to them is behaving // correctly rather than losing the value. let mut g = EditGraph::default_chain(); for cap in g.capabilities() { for p in &cap.params { if let ParamKind::Scalar { max, precision, .. } = p.kind { let step = 10f32.powi(i32::from(precision)); let target = (max * 0.5 * step).round() / step; g.set_param(cap.id, p.id, target); assert_eq!( g.param(cap.id, p.id), Some(target), "{}.{} did not round-trip", cap.id, p.id ); } } } } #[test] fn adding_an_operation_needs_no_ui_change() { // FR-DEV-3c, asserted structurally: everything a control needs is // reachable from the capability list, so a new operation appears // without the UI naming it. If this test needs editing to add an // operation, the abstraction has leaked. let g = EditGraph::default_chain(); let rendered: Vec = g .capabilities() .iter() .flat_map(|c| { c.params.iter().map(move |p| match p.kind { ParamKind::Scalar { min, max, precision, .. } => format!( "{}/{}: slider {min}..{max} @{precision} = {}", c.label.0, p.label.0, p.value ), ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0), ParamKind::Enum { variants } => format!( "{}/{}: choice of {} = {}", c.label.0, p.label.0, variants.len(), p.value ), }) }) .collect(); // Counted from the chain, not a literal: this test must not need // editing when an operation is added, or it would be asserting the // opposite of what it claims. let expected: usize = g.capabilities().iter().map(|c| c.params.len()).sum(); assert_eq!(rendered.len(), expected); assert!(rendered.iter().all(|r| !r.is_empty())); assert!( expected > 40, "the chain should now carry the mixer's 36 parameters too" ); } #[test] fn enabling_another_operation_does_change_the_structure() { let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); let before = g.compose().structure_hash; g.set_param( crate::ops::saturation::ID, crate::ops::saturation::SATURATION, 30.0, ); assert_ne!(before, g.compose().structure_hash); } // ---- invalidation scoping (FR-DEV-3d) -------------------------------- use crate::descriptor::OpId; use crate::operation::Affects; const PROBE: OpId = OpId("detail_probe"); const PROBE_RADIUS: ParamId = ParamId("radius"); #[test] fn moving_a_detail_parameter_leaves_every_earlier_stage_alone() { // FR-DEV-3d's headline, and the thing `Affects::Detail` was added to // make true: dragging a sharpening slider must not re-run the // demosaic, the framing, or the fused colour pass. The demosaic is not // a key here at all — no parameter in this graph can reach it — and // the other two must come out unchanged. let mut g = EditGraph::with_detail_probe(); let before = g.invalidation(); g.set_param(PROBE, PROBE_RADIUS, 0.05); let after = g.invalidation(); assert_ne!( before.of(Affects::Detail), after.of(Affects::Detail), "the detail stage's own key must move" ); assert_eq!( before.through(Affects::Colour), after.through(Affects::Colour), "the fused colour pass's result is still valid, so its cached \ linear intermediate must be reusable" ); assert_eq!( before.through(Affects::Geometry), after.through(Affects::Geometry) ); } #[test] fn moving_a_colour_parameter_leaves_geometry_alone_and_redoes_detail() { // The other direction, and the half that is easy to get wrong by // wishing. Exposure does not touch the framing — FR-DEV-3d says so in // as many words. It *does* invalidate the detail stage's output, // because the detail stage reads what the colour pass wrote, and // pretending otherwise would show a sharpened version of the previous // exposure. The stage's own parameters are still untouched, which is // what `of` reports and `through` does not. let mut g = EditGraph::with_detail_probe(); g.set_param(PROBE, PROBE_RADIUS, 0.05); let before = g.invalidation(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); let after = g.invalidation(); assert_eq!( before.through(Affects::Geometry), after.through(Affects::Geometry), "adjusting exposure shall not re-tile geometry (FR-DEV-3d)" ); assert_ne!(before.of(Affects::Colour), after.of(Affects::Colour)); assert_eq!( before.of(Affects::Detail), after.of(Affects::Detail), "the sharpening settings did not change" ); assert_ne!( before.through(Affects::Detail), after.through(Affects::Detail), "but its input did, so its cached output is stale" ); } #[test] fn cropping_invalidates_everything_downstream_of_it() { // Geometry is upstream of both other stages: it decides which source // pixel every colour is read from, and — because the detail stage runs // at render resolution — how many render pixels a kernel spans. let mut g = EditGraph::with_detail_probe(); g.set_param(PROBE, PROBE_RADIUS, 0.05); let before = g.invalidation(); g.set_crop(CropRect { x: 0.1, y: 0.1, width: 0.5, height: 0.5, }); let after = g.invalidation(); assert_ne!(before.of(Affects::Geometry), after.of(Affects::Geometry)); assert_ne!( before.through(Affects::Colour), after.through(Affects::Colour) ); assert_ne!( before.through(Affects::Detail), after.through(Affects::Detail) ); // Scoped, though: neither later stage's *own* settings moved. assert_eq!(before.of(Affects::Colour), after.of(Affects::Colour)); assert_eq!(before.of(Affects::Detail), after.of(Affects::Detail)); } #[test] fn scrolling_the_view_invalidates_the_render_without_being_an_edit() { // The view rect is not an edit — it is excluded from the sidecar, the // structure hash and `is_active` — but it absolutely is an input to // every pixel. A key that ignored it would leave the previous part of // the photograph on screen after a pan, which looks like a repaint bug // and is a cache bug. let mut g = EditGraph::default_chain(); let before = g.invalidation(); g.framing_mut().set_view(CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }); assert_ne!( before.of(Affects::Geometry), g.invalidation().of(Affects::Geometry) ); } #[test] fn returning_a_slider_to_where_it_was_returns_the_key() { // A cache key that drifted with the *path* rather than the state would // never hit after an undo, which is the moment it is most wanted. let mut g = EditGraph::with_detail_probe(); let origin = g.invalidation(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.5); g.set_param(PROBE, PROBE_RADIUS, 0.05); assert_ne!(origin, g.invalidation()); g.set_param(exposure::ID, exposure::EXPOSURE, 0.0); g.set_param(PROBE, PROBE_RADIUS, 0.0); assert_eq!(origin, g.invalidation(), "the state is what is hashed"); } #[test] fn a_local_adjustment_belongs_to_the_colour_stage() { // A mask layer's chain is fused into the same dispatch as the global // one, so changing it is a colour change and nothing more. Its // *shape* counts too: which pixels the dispatch treats differently is // as much a part of the result as by how much. use crate::mask::{MaskLayer, MaskSource}; let mut g = EditGraph::with_detail_probe(); let before = g.invalidation(); g.masks_mut().push(MaskLayer::new( "l1", MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2, }, )); let with_layer = g.invalidation(); assert_ne!(before.of(Affects::Colour), with_layer.of(Affects::Colour)); assert_eq!( before.of(Affects::Geometry), with_layer.of(Affects::Geometry) ); assert_eq!(before.of(Affects::Detail), with_layer.of(Affects::Detail)); // Moving the gradient is a different mask, so a different result. if let Some(layer) = g.masks_mut().get_mut("l1") { layer.source = MaskSource::Linear { centre: (0.2, 0.7), angle: 0.4, width: 0.2, }; } assert_ne!( with_layer.of(Affects::Colour), g.invalidation().of(Affects::Colour) ); } #[test] fn the_render_scale_folds_in_the_crop_and_the_zoom() { // What a detail operation is handed, and the reason it does not need // to know that a crop or a zoom happened: both arrive already folded // into one ratio. let mut g = EditGraph::default_chain(); let source = (6000, 4000); // Fit: a 1500px panel over a 6000px frame is a quarter scale. let fit = g.render_scale(source, (1500, 1000)); assert!((fit.ratio() - 0.25).abs() < 1e-3); // Zoomed to 1:1 — the view rect shrinks to what the panel can hold, // the render target keeps its size, and the preview becomes exact. g.framing_mut().set_view(CropRect { x: 0.25, y: 0.25, width: 0.25, height: 0.25, }); let one_to_one = g.render_scale(source, (1500, 1000)); assert!((one_to_one.ratio() - 1.0).abs() < 1e-3); assert!(one_to_one.resolves(1.0)); // A crop shows fewer source pixels in the same panel, which is more // render pixels each — a sharpening radius genuinely does grow. let mut cropped = EditGraph::default_chain(); cropped.set_crop(CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }); let after = cropped.render_scale(source, (1500, 1000)); assert!(after.ratio() > fit.ratio()); } }