//! TRACES: FR-DEV-1 //! 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 std::sync::Arc; use crate::descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation, }; use crate::framing::{CropRect, Framing}; use crate::lens::LensProfile; use crate::mask::MaskStack; use crate::operation::{compose_full, ComposedShader, Operation, Uniform}; use crate::ops; use crate::preset::{Preset, Scope}; use crate::spot::SpotSet; use crate::state::{EditState, FilmRebake, FilmRef}; /// 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; both readers refuse an operation that declares none. pub attributes: Vec, } /// 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 } } /// TRACES: FR-DSP-2 /// How far past the framing's own footprint [`EditGraph::source_region`] /// reaches when a lens warp is active, as a fraction of the frame on each /// side. Distortion profiles move a corner by a few per cent of the frame; a /// window short of what the warp reads would render the missing strip black. pub const WARP_MARGIN: f32 = 0.04; /// 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: Arc, /// TRACES: FR-DEV-3f /// The film stock this edit renders through, if any. /// /// Apart from `ops` for the same reason `masks` is, and the reason /// [`crate::sidecar::Version::rating`] is a top-level key: a stock is not /// a scalar and not a slider. It is a *choice of material*, named by an /// id, from which the tables in `ops::film_sim` are derived. /// /// Both halves live here together — the id that persists and the tables /// that render — because they are one fact, and holding them apart is how /// a sidecar comes to name one stock while the shader draws another. film: Option, /// TRACES: FR-DEV-8 /// The repairs (`docs/dev/spot-removal.md`). /// /// Apart from `ops` for the third time and the same reason: a spot is not /// a scalar, and a list of them is not a slider. It sits beside the masks /// rather than among them because it is not a mask either — a mask says /// *where* an adjustment applies, and a spot says where a piece of the /// photograph comes from. spots: SpotSet, /// The lens corrections that rewrite coordinates: distortion and lateral /// chromatic aberration (`docs/dev/architecture.md` §5.2). /// /// Apart from `ops` for the fourth time, and this one is not about shape /// but about direction. Every [`Operation`] is a function from colour to /// colour, and these run *before* a colour exists: they decide which /// source pixel is read, and CA decides it three times over. See /// [`crate::lens`] for why that cannot be expressed as an operation. /// /// Beside [`Self::framing`] in every way that matters — the two compose /// into one coordinate map, and neither can be applied without the other's /// result — but held separately because framing also changes the output's /// dimensions, which a warp never does. warps: Vec>, /// The lens profile the corrections above were given, if any. /// /// Kept as well as fanned out, because the corrections hold it in a form /// nothing can read back: each has folded its own share of the profile /// into private coefficients. Something has to be able to answer "is this /// photograph corrected from a measurement, or by hand?" — the interface /// is required to say so plainly rather than let an automatic correction /// silently do nothing — and this is the only place that can. /// /// Not in [`EditState`], not in the sidecar, and not undoable: it is /// derived from the file's EXIF and a database, exactly as the film's /// baked tables are derived from a stock's id. lens_profile: Option, /// Whether the profile above is being used. /// /// **The one part of automatic lens correction that is an edit.** The /// coefficients are a measurement of the lens and belong to the file; /// whether to accept them is the photographer's, and it is the answer a /// tick box in the panel gives. So this rides with the parameters rather /// than with the profile: it is published as /// [`crate::lens::profile_switch`], captured by [`Preset`], stored in the /// sidecar and replayed by the undo stack, all by the one road every other /// setting travels (FR-DEV-3c). /// /// True on a fresh graph, because a profile that was found is a /// correction the photograph asked for — see /// [`crate::descriptor::ParamDescriptor::switch_on`]. lens_profile_applied: bool, } /// TRACES: FR-DEV-3f /// A chosen stock, and what it bakes to. #[derive(Debug, Clone, PartialEq)] pub struct Film { /// The stock's id, e.g. `kodak_portra_400`. **This is what persists.** /// /// A name rather than an index into the stock list, because the list is /// data-driven: stocks are files, users add them, and an index would mean /// installing a profile silently changed which film every existing /// photograph was developed on. pub stock: String, /// The paper it is printed on, if it is printed. `None` views the film /// directly — right for a reversal stock, and for a negative it is the /// scan, orange mask and all. pub print: Option, /// The baked tables. **Not persisted**: they are derived from the two ids /// above plus the node's own exposure parameters, and re-baking is /// milliseconds. pub tables: crate::ops::FilmTables, } 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: Arc::new(MaskStack::new()), film: None, spots: SpotSet::new(), // Distortion first, then CA, and the order is the correction's // rather than a preference. Each warp receives the position the // previous one produced, and lateral CA is a magnification about // the optical axis of the *undistorted* frame — measured on a // barrel-distorted one it would be fitted to a radius the lens // profile does not describe. warps: vec![ Box::new(crate::ops::Distortion::new()), Box::new(crate::ops::Aberration::new()), ], lens_profile: None, lens_profile_applied: true, } } /// 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 } /// TRACES: FR-DEV-8 /// The repairs. pub fn spots(&self) -> &SpotSet { &self.spots } pub fn spots_mut(&mut self) -> &mut SpotSet { &mut self.spots } /// The local adjustment stack. pub fn masks(&self) -> &MaskStack { &self.masks } /// The stack, to modify. /// /// Clones on write. The stack is shared with every [`EditState`] snapshot /// taken since it last changed — the undo stack holds a run of them — so /// this is where a shared stack becomes this graph's own again. Callers /// see no difference; what it buys is that recording a slider drag does /// not deep-copy a painted mask once a frame. pub fn masks_mut(&mut self) -> &mut MaskStack { Arc::make_mut(&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) } /// TRACES: FR-DSP-2 | NFR-RES-2 /// The part of the source the visible region reads, as a rectangle in /// normalised source coordinates, clamped to the frame. /// /// For a photograph larger than one texture: a render of part of it — /// the canvas zoomed in, one tile of an export — binds only this window /// of the source (see `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS`). /// /// The framing is walked on the CPU with [`Framing::source_at`], along /// the border and across the interior, so a straightened or keystoned /// view gets the box around the quadrilateral it actually reads. The lens /// warps have no CPU mirror, so when one is active the box is widened /// by [`WARP_MARGIN`] of the frame on each side: a distortion profile /// moves a corner by a few per cent of the frame at most. `halo`, in /// source pixels, is added on top — the detail stage's reach, which reads /// beyond the pixels it writes. pub fn source_region(&self, source: (u32, u32), halo: u32) -> crate::framing::CropRect { const STEPS: usize = 16; let (sw, sh) = (source.0.max(1), source.1.max(1)); let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); for j in 0..=STEPS { for i in 0..=STEPS { let out = (i as f32 / STEPS as f32, j as f32 / STEPS as f32); let (x, y) = self.framing.source_at(out, sw, sh); x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); } } let warp = if crate::lens::compose_warps(&self.warps).is_active() { WARP_MARGIN } else { 0.0 }; // Two pixels beyond the halo: the bilinear tap's second texel, and // the rounding of the box to whole pixels by the caller. let px = (halo as f32 + 2.0) / sw as f32; let py = (halo as f32 + 2.0) / sh as f32; let x0 = (x0 - warp - px).clamp(0.0, 1.0); let y0 = (y0 - warp - py).clamp(0.0, 1.0); let x1 = (x1 + warp + px).clamp(0.0, 1.0); let y1 = (y1 + warp + py).clamp(0.0, 1.0); crate::framing::CropRect { x: x0, y: y0, width: (x1 - x0).max(0.0), height: (y1 - y0).max(0.0), } } /// 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> { self.ops.iter().map(|o| o.descriptor()).collect() } /// TRACES: FR-DEV-3 /// Apply a lens profile's measured coefficients to every correction that /// wants some, or clear them all with `None`. /// /// **Derived state, not an edit.** A profile comes from the file's EXIF /// plus a database this crate does not link, so it is not a parameter, is /// not in the sidecar, and is not undoable. What *is* an edit is the manual /// trim beside it: each correction composes the profile with its own /// slider, so a photographer can lean on the measurement, override it, or /// work without one. /// /// **Clearing matters as much as setting.** Opening a photograph from an /// unrecognised lens must pass `None` rather than simply not calling this: /// a graph reused across images would otherwise correct this frame for the /// optics of the last one, which is both wrong and invisible. /// /// Fans out over the warps and the operations alike. Which trait a /// correction implements is a fact about where it sits relative to the /// fetch, and no business of the caller's — see /// [`crate::Operation::set_lens_profile`]. pub fn set_lens_profile(&mut self, profile: Option) { self.lens_profile = profile; self.fan_out_lens_profile(); } /// The profile this photograph was matched to, if any. /// /// Reports what was *found*, not what is in effect: a profile the /// photographer has switched off is still the profile for this lens, and /// the switch is what says whether it is being used. A caller wanting the /// coefficients that are actually in the shader asks /// [`Self::lens_profile_applied`] as well — which is what the interface's /// lens line does, because "corrected" and "correction available, off" are /// two different things to tell a photographer. pub fn lens_profile(&self) -> Option<&LensProfile> { self.lens_profile.as_ref() } /// TRACES: FR-DEV-3 /// Whether the matched profile is being applied. pub fn lens_profile_applied(&self) -> bool { self.lens_profile_applied } /// TRACES: FR-DEV-3 /// Use the matched profile, or decline it. /// /// Reached through `set_param` by the panel, like every other setting; /// this is the named door for a caller that has a `bool` rather than a /// parameter id. /// /// Setting this with no profile matched is meaningful and harmless: a /// preset carrying the switch may land on a photograph whose lens the /// database has never heard of, and remembering the answer costs nothing. pub fn set_lens_profile_applied(&mut self, applied: bool) { self.lens_profile_applied = applied; self.fan_out_lens_profile(); } /// Hand every correction the coefficients it should be using. /// /// `None` where the switch is off, which is the same call opening an /// unrecognised lens makes — the corrections cannot tell the difference /// between "no profile" and "not this one, thank you", and have no reason /// to. fn fan_out_lens_profile(&mut self) { let profile = self.lens_profile.filter(|_| self.lens_profile_applied); for warp in &mut self.warps { warp.set_profile(profile.as_ref()); } for op in &mut self.ops { op.set_lens_profile(profile.as_ref()); } } /// Descriptors for the coordinate-domain lens corrections, in order. /// /// The counterpart to [`Self::descriptors`] and split from it for the same /// reason framing is absent there: a warp emits its own block in the /// generated shader rather than a colour fragment, so the codegen tests /// that count `---- ` markers have to know which kind they are counting. /// A UI wanting everything still reads [`Self::capabilities`], where all /// three kinds arrive together and indistinguishably (FR-DEV-3a). pub fn warp_descriptors(&self) -> Vec> { self.warps.iter().map(|w| w.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.clone(), } }); // The lens corrections first, matching where they sit in the shader: // they rewrite the coordinate before any colour is fetched, so nothing // below them can be judged until they are right. It also puts the // three optical corrections together in the panel — these two and the // vignetting node, which is an ordinary operation and arrives above // through `ops`. let warps = self.warps.iter().map(|w| { let desc = w.descriptor(); OpCapability { id: desc.id, label: desc.label, active: w.is_active(), params: desc .params .iter() .map(|p| ParamCapability { id: p.id, label: p.label, kind: p.kind.clone(), default: p.default, value: w.param(p.id), facet: p.facet, }) .collect(), // No preferred widget. A distortion amount and the two CA // scales are ordinary scalars, and plain sliders — the // fallback every frontend implements — are the right control // for them. presentation: None, attributes: desc.attributes.clone(), } }); // 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.clone(), }; // The lens profile switch, ahead of the corrections it drives — and // **only when a profile was matched**. A tick box on a photograph // whose lens the database has never heard of would be a control that // does nothing, which is the failure `dr_lens` names: an automatic // correction is allowed to be unavailable and is not allowed to look // available and be inert. Where there is no profile the interface says // so in words instead. let switch = self.lens_profile.map(|_| { let desc = crate::lens::profile_switch::descriptor(); OpCapability { id: desc.id, label: desc.label, // A profile that is on is doing something to this photograph, // which is what the panel's modified marker is for. Off is the // departure from default, and reads as one either way. active: self.lens_profile_applied, params: desc .params .iter() .map(|p| ParamCapability { id: p.id, label: p.label, kind: p.kind.clone(), default: p.default, value: if self.lens_profile_applied { 1.0 } else { 0.0 }, facet: p.facet, }) .collect(), // An ordinary switch. Nothing about it wants the canvas. presentation: None, attributes: desc.attributes.clone(), } }); switch .into_iter() .chain(warps) .chain(ops) .chain(std::iter::once(framing)) .collect() } /// TRACES: FR-DEV-3a /// What one operation's uniforms currently evaluate to. /// /// The same numbers [`Self::compose`] would bake into the uniform block, /// asked for one node rather than for the whole chain. `None` where no /// operation carries that id — the framing and the lens warps are not in /// `ops`, and neither publishes uniforms of this kind. /// /// **Why anything outside composition wants these.** A widget that reads /// the photograph rather than driving it — an eyedropper, above all — has /// to invert the operation: it knows what the pixel is and what it should /// become, and needs the parameter values that get it there. The mapping /// from parameters to effect lives in the node's own declaration, and /// this is the only way to ask it what that mapping currently says /// without composing a shader and rendering one. See [`crate::neutral`], /// which is the one caller. /// /// Cheap: a declared node evaluates a handful of small arithmetic /// expressions. It is not, however, a per-frame path, and it is not on /// one — composition reads the same values by its own route. pub fn uniforms_of(&self, op: OpId) -> Option> { self.ops .iter() .find(|o| o.descriptor().id == op) .map(|o| o.uniforms()) } /// 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. /// TRACES: FR-DEV-3f /// Develop on a film stock, or stop doing so. /// /// One call for the id and the tables together, because they are one fact. /// Offered to every operation rather than to the one that wants it, /// because the graph holds `Box` and knowing which concrete /// type is which is exactly what it is organised not to know (ARCH §3.4). /// The default implementation ignores it, so this costs a virtual call per /// node on an action a user takes by hand. pub fn set_film(&mut self, film: Option) { for op in &mut self.ops { op.set_film_tables(film.as_ref().map(|f| &f.tables)); } self.film = film; } /// The stock this edit is being developed on. pub fn film(&self) -> Option<&Film> { self.film.as_ref() } /// TRACES: FR-DEV-5 | FR-CAT-8 /// The whole edit, as data — what an undo step and a sidecar are both /// made of. /// /// **The pattern below is exhaustive on purpose.** It is the only thing /// standing between a new kind of graph state and an undo that quietly /// ignores it, which is exactly how the mask stack came to be missing /// from the history for as long as it was. Never add `..` to it: a field /// added to this struct should fail to compile here until somebody has /// decided whether stepping backwards has to put it back. See /// [`crate::state`]. pub fn state(&self) -> EditState { let Self { // Both reached through `capabilities`, which is the one walk the // develop panel, the clipboard and the sidecar already make — so // an operation is undoable by virtue of being in the chain, with // nothing to register (FR-DEV-3c). ops: _, framing: _, // Reached through `capabilities` with the other two. A warp's // parameters are ordinary scalars once they are in that list, so // the sidecar, the clipboard and the undo stack carry them with // nothing registered anywhere (FR-DEV-3c). warps: _, // Derived from the file and a database, so it is rebuilt on open // rather than restored — the same reason the film's tables travel // as an id and not as numbers. lens_profile: _, // Whether that profile is *used* is an edit, and it is in the // state below: it reaches `Preset::capture` through // `capabilities`, with the operations and the warps and for the // same reason (FR-DEV-3c). lens_profile_applied: _, masks, film, spots, } = self; EditState { // Without the film: it has a field of its own below, and one // edit must not have two places to disagree about its stock. params: Preset::capture_params(self), // A refcount bump. See `EditState::masks` for why that matters on // a path called once a frame. masks: Arc::clone(masks), // The names travel; the tables do not. They are derived, and this // crate cannot rebuild them — hence `FilmRebake`. film: film.as_ref().map(|f| FilmRef { stock: f.stock.clone(), print: f.print.clone(), }), spots: spots.clone(), } } /// TRACES: FR-DEV-5 | FR-CAT-8 /// Put `state` back, replacing whatever this graph held. /// /// A *replacement*, not an overlay: a parameter absent from the state /// means default, and a state with no mask blocks means an edit with no /// local adjustments rather than an edit that keeps whatever was on /// screen. That is the same rule [`Preset::apply`] and /// [`crate::Version::apply`] keep, and for the same reason — "the /// photograph is now as it was" is the whole claim the call makes. /// /// The **viewport survives**, because [`Preset::apply`] preserves it. Zoom /// says where the user is looking rather than what the picture is, and an /// undo that refitted the frame would read as having navigated somewhere. /// /// The pattern below is exhaustive for the reason [`Self::state`]'s is. pub fn set_state(&mut self, state: &EditState) -> FilmRebake { let EditState { params, masks, film, spots, } = state; // At full scope. `Scope` is a question about what a paste carries // *between* photographs; this is one photograph's own edit being put // back, so there is nothing to leave behind. // // What the parameters say about the film is discarded: the stock is // `film`'s to decide, below, and clearing it is what happens there // either way. let _ = params.apply(self, Scope::everything()); self.masks = Arc::clone(masks); self.spots = spots.clone(); // Cleared either way, and when a stock is named the caller bakes it. // Left standing, the tables now in the graph would be the ones baked // from the film node's *previous* exposure sliders — and those sliders // were just replaced, so the restored state would render through the // film of the state it replaced. Clearing is the conservative half of // that; `Wanted` is the half that gets it back. self.set_film(None); match film { None => FilmRebake::NotNeeded, Some(want) => FilmRebake::Wanted(want.clone()), } } pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) { if op == crate::lens::profile_switch::ID { if param != crate::lens::profile_switch::APPLY { log::warn!("unknown parameter {param} on {op}; ignoring"); return; } // Accepted whether or not a profile was matched, for the reason // `set_lens_profile_applied` gives: a preset may carry the answer // to a photograph that has nothing to apply it to. self.set_lens_profile_applied(value != 0.0); return; } if op == crate::framing::ID { // Bound rather than chained: `descriptor()` hands back an owned // `Arc` now, so a `param()` borrowed straight out of the call // would outlive the temporary it came from. let descriptor = self.framing.descriptor(); let Some(desc) = descriptor.param(param) else { log::warn!("unknown parameter {param} on {op}; ignoring"); return; }; self.framing.set_param(param, desc.clamp(value)); return; } // The warps, before the operations. Their ids cannot collide with an // operation's — `ops/` and the warp list are disjoint by construction, // and `declared_parity` asserts the chain is exactly what `ops/` // declares — so the order is for readability rather than precedence. if let Some(warp) = self.warps.iter_mut().find(|w| w.descriptor().id == op) { let descriptor = warp.descriptor(); let Some(desc) = descriptor.param(param) else { log::warn!("unknown parameter {param} on {op}; ignoring"); return; }; warp.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::lens::profile_switch::ID { return (param == crate::lens::profile_switch::APPLY) .then_some(if self.lens_profile_applied { 1.0 } else { 0.0 }); } if op == crate::framing::ID { return self .framing .descriptor() .param(param) .map(|_| self.framing.param(param)); } if let Some(warp) = self.warps.iter().find(|w| w.descriptor().id == op) { return warp.descriptor().param(param).map(|_| warp.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); } } for warp in &mut self.warps { for p in &warp.descriptor().params { warp.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 = Arc::new(MaskStack::new()); // The film goes too, for the reason the masks do. Restoring it is the // *caller's* job rather than `Version::apply`'s: a sidecar names a // stock, and turning a name into tables needs the profile database, // which this crate deliberately does not link (ARCH §6.5a). self.set_film(None); // The *matched profile* stays — it is the file's, not the edit's, and // a reset does not change which lens took the photograph. What returns // to default is the answer to whether to use it, which is on. self.set_lens_profile_applied(true); } /// 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() && self.spots.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, &self.spots, &self.warps, ) } /// TRACES: FR-MRG-2 /// The camera-space tap for a merge: this edit's lens corrections and /// nothing else of it, stored at full precision. See /// [`crate::operation::compose_camera_linear`]. pub fn compose_camera_linear(&self, view: crate::framing::CropRect) -> ComposedShader { crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view) } /// TRACES: FR-DEV-3 /// The camera-space tap over one patch of what is on the canvas. /// /// `patch` is in fractions of the visible region — the coordinates a /// click on the canvas arrives in — and is laid over this edit's own /// framing: crop, view, rotation and all, so a fraction of the canvas is /// a fraction of the probe. Nothing else of the edit: no operation, no /// mask, no repair. Rendering only the patch is what lets a small target /// cover every sensor pixel under it rather than sampling one in fifty; /// see [`crate::operation::compose_camera_probe`] for why the white /// balance picker reads from here and not from the display. /// /// The patch is centred where asked and held to the view's own minimum /// extent: at a deep zoom a patch a fraction of the view would be /// smaller than a view may be, and letting `set_view` widen it from one /// corner would move the sample off the point that was clicked. pub fn compose_camera_probe(&self, patch: crate::framing::CropRect) -> ComposedShader { use crate::framing::CropRect; let mut framing = self.framing; let view = framing.view(); let width = (patch.width * view.width).max(CropRect::MIN_EXTENT); let height = (patch.height * view.height).max(CropRect::MIN_EXTENT); let cx = view.x + (patch.x + patch.width * 0.5) * view.width; let cy = view.y + (patch.y + patch.height * 0.5) * view.height; framing.set_view(CropRect { x: (cx - width * 0.5).clamp(0.0, 1.0 - width), y: (cy - height * 0.5).clamp(0.0, 1.0 - height), width, height, }); crate::operation::compose_camera_probe(&self.warps, &framing) } /// TRACES: FR-DEV-19c /// [`Self::compose_for`], with one layer's mask drawn over the picture. /// /// **The screen's composition, and only the screen's.** The reveal is not /// on the graph and cannot be: it is how a photographer is looking at an /// edit, not part of one, so it arrives as an argument to the one call /// that draws the canvas. Every other path through this type composes /// without it and could not ask for it if it wanted to. /// /// The revealed layer renders whether or not it carries an adjustment — /// which is the whole point, since a fresh selection carries none — so the /// mask array must be rasterised for the same `reveal`. See /// [`crate::mask::MaskStack::rendered`] for what the two have to agree on. pub fn compose_revealing( &self, output: dr_types::ColourSpace, reveal: Option<&crate::mask::Reveal>, ) -> ComposedShader { crate::operation::compose_full_revealing( &self.ops, &self.framing, output, &self.masks, &self.spots, &self.warps, reveal, ) } /// 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).within((fw, fh)) } /// TRACES: FR-DEV-3 | FR-DSP-1 /// Generate the detail stage for this edit at one resolution. /// /// 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. /// /// No output space: since D19 no detail pass encodes. The fused pass's /// view pass reads what the last one wrote and performs the view transform /// and the output transform, so it is [`Self::compose_for`] alone that /// names the space. /// /// `source` is the demosaiced image's size and `render` the size being /// drawn. The scale is worked out here rather than handed in, because the /// repairs need the *source* size as well — a spot is stored in normalised /// source coordinates and has to be put through the framing to find out /// where it lands on this render, and a [`crate::detail::RenderScale`] /// describes the region on screen rather than the photograph. pub fn compose_detail( &self, source: (u32, u32), render: (u32, u32), ) -> crate::detail::ComposedDetail { let scale = self.render_scale(source, render); let spots = self.spots.passes(&self.framing, source, scale); crate::detail::compose_detail_with(&self.ops, &spots, scale) } /// 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 warps belong to the geometry key, not the colour one: they decide // which source pixel a colour is read from, so a cached *result* of // this stage is wrong the moment one moves. Hashed by id as well as by // value, so two warps swapping their amounts is not the same edit. for warp in &self.warps { let descriptor = warp.descriptor(); geometry = hash_bytes(geometry, descriptor.id.0.as_bytes()); for p in &descriptor.params { geometry = hash_bytes(geometry, p.id.0.as_bytes()); geometry = mix( geometry, u64::from(crate::operation::canonical_bits(warp.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 = mix(colour, u64::from(layer.enabled)); colour = mix(colour, u64::from(layer.invert)); colour = mix( colour, u64::from(crate::operation::canonical_bits(layer.opacity)), ); // Every part, in order, because the mask is the fold over them: a // part added, removed, reshaped or joined the other way round is a // different shape even when nothing else moved. The order is // folded in by construction — the same parts joined the other way // round hash differently because they arrive in the other order. for part in layer.parts() { colour = hash_bytes(colour, part.id.as_bytes()); colour = hash_bytes(colour, part.join.name().as_bytes()); colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes()); colour = mix(colour, u64::from(part.invert)); // Hiding a part changes the fold as surely as removing it. colour = mix(colour, u64::from(part.hidden)); colour = hash_bytes(colour, part.falloff.name().as_bytes()); for v in [part.feather, part.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))); } } // TRACES: FR-DEV-8 // The repairs belong to the detail stage, because that is where they // run. Moving a spot therefore re-runs the neighbourhood passes and // leaves the fused colour dispatch and the demosaic alone, which is // the difference between a spot that follows the finger and one that // stutters (FR-DEV-3d). detail = self.spots.hash(detail); 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. // // Two blocks, the view transform and the camera profile: both are // composed at their defaults, because a photograph with no view // transform is a scan rather than a picture (FR-DEV-3j) and a raw // with a profile is rendered through it (D20). They are still neutral // in the sense that matters here — nothing moved, nothing is written // — and every adjustment is absent. let g = EditGraph::default_chain(); assert!(g.is_neutral()); let source = g.compose().source; assert_eq!( source.matches("---- ").count(), 2, "a neutral graph must generate no adjustment blocks" ); assert!(source.contains("---- camera_profile ----")); assert!(source.contains("---- view_transform ----")); } #[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 — and // the view transform and camera profile, which every render has // (FR-DEV-3j, D20). 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(), 4); assert!(shader.source.contains("---- view_transform ----")); 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); } /// The whole point of putting the warps in `capabilities`: everything that /// walks that list carries them, with nothing registered anywhere. #[test] fn a_warp_is_carried_by_the_machinery_it_never_told_about_itself() { use crate::ops::{aberration, distortion}; let mut g = EditGraph::default_chain(); g.set_param(distortion::ID, distortion::AMOUNT, 40.0); g.set_param(aberration::ID, aberration::RED, 25.0); assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0)); assert_eq!(g.param(aberration::ID, aberration::RED), Some(25.0)); // Through the same capture/apply the sidecar, the clipboard and the // undo stack all use. let state = g.state(); let mut restored = EditGraph::default_chain(); // No film in this graph, so nothing is owed; the result is asserted // rather than dropped because ignoring it elsewhere would leave a // photograph rendering without its stock. assert_eq!(restored.set_state(&state), FilmRebake::NotNeeded); assert_eq!( restored.param(distortion::ID, distortion::AMOUNT), Some(40.0), "a distortion correction did not survive a state round trip, so \ reopening the photograph would silently drop it" ); assert_eq!(restored.param(aberration::ID, aberration::RED), Some(25.0)); } /// A profile has to reach all three corrections, across both traits. /// /// The failure this guards is the quiet one: a profile that reached the /// warps and not the vignetting node would correct the geometry and leave /// the corners dark, which looks like an under-corrected lens rather than /// like a wiring fault. #[test] fn a_lens_profile_reaches_every_correction_that_wants_one() { use crate::lens::{LensProfile, Tca}; use crate::ops::{aberration, distortion, vignetting}; let mut g = EditGraph::default_chain(); for id in [distortion::ID, aberration::ID, vignetting::ID] { assert_eq!( g.param(id, ParamId("amount")).unwrap_or(0.0), 0.0, "{id} should start neutral" ); } g.set_lens_profile(Some(LensProfile { distortion: Some(distortion::PtLens { a: 0.0, b: -0.012, c: 0.0, }), tca: Some(Tca { red_scale: 1.000_32, blue_scale: 0.999_93, }), vignetting: Some(vignetting::Pa { k1: -0.42, k2: 0.05, k3: 0.0, }), })); // Every correction is now doing something, with every slider still at // its default — which is the whole point of a profile. let source = g.compose().source; for marker in [ "---- warp: distortion ----", "---- warp: aberration ----", "---- vignetting ----", ] { assert!( source.contains(marker), "a profile did not reach {marker}: {source}" ); } // And clearing it puts the photograph back, which is what opening an // image from an unrecognised lens has to do. g.set_lens_profile(None); let cleared = g.compose().source; assert!(!cleared.contains("---- warp: ")); assert!(!cleared.contains("---- vignetting ----")); assert!(g.lens_profile().is_none()); } /// A measured profile, for the switch's tests. Coefficients are one real /// wide-angle's, rounded — what matters is that each of the three /// corrections gets something to do. fn measured() -> crate::lens::LensProfile { use crate::lens::{LensProfile, Tca}; use crate::ops::{distortion, vignetting}; LensProfile { distortion: Some(distortion::PtLens { a: 0.0, b: -0.012, c: 0.0, }), tca: Some(Tca { red_scale: 1.000_32, blue_scale: 0.999_93, }), vignetting: Some(vignetting::Pa { k1: -0.42, k2: 0.05, k3: 0.0, }), } } /// TRACES: FR-DEV-3 /// The switch takes the whole profile out of the shader, and puts it back. /// /// The control the panel draws is this call, and what it has to mean is /// "develop this photograph as if the database had never heard of the /// lens" — all three corrections, not the geometry alone. #[test] fn declining_the_profile_removes_every_correction_it_was_driving() { use crate::lens::profile_switch; let mut g = EditGraph::default_chain(); g.set_lens_profile(Some(measured())); assert!(g.compose().source.contains("---- warp: distortion ----")); g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0); let declined = g.compose().source; assert!(!declined.contains("---- warp: "), "{declined}"); assert!(!declined.contains("---- vignetting ----")); // The profile itself is untouched. It is a fact about the file, and // the interface still has to be able to say which lens this was. assert!( g.lens_profile().is_some(), "declining a profile must not forget it, or the switch could \ never be turned back on" ); g.set_param(profile_switch::ID, profile_switch::APPLY, 1.0); assert!(g.compose().source.contains("---- warp: distortion ----")); } /// TRACES: FR-DEV-3 /// The manual trims keep working with the profile declined. /// /// The two are independent by construction — each correction composes the /// profile with its own slider — and that is what makes the switch safe to /// offer: turning it off is "correct this by hand", not "stop correcting". #[test] fn declining_the_profile_leaves_the_manual_corrections_alone() { use crate::lens::profile_switch; use crate::ops::distortion; let mut g = EditGraph::default_chain(); g.set_lens_profile(Some(measured())); g.set_param(distortion::ID, distortion::AMOUNT, 40.0); g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0); assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0)); assert!( g.compose().source.contains("---- warp: distortion ----"), "the slider still bends the frame with the profile switched off" ); } /// TRACES: FR-DEV-3 /// The switch is only offered where there is a profile to switch. /// /// `dr_lens`'s rule, as a property of the capability list: a tick box on a /// photograph whose lens the database has never heard of would be a /// control that looks available and does nothing, which is the failure the /// automatic correction is supposed to avoid rather than an instance of /// it. #[test] fn the_switch_is_absent_until_a_profile_is_matched() { use crate::lens::profile_switch; let mut g = EditGraph::default_chain(); assert!( !g.capabilities().iter().any(|c| c.id == profile_switch::ID), "an unmatched lens must not grow a tick box" ); g.set_lens_profile(Some(measured())); let cap = g .capabilities() .into_iter() .find(|c| c.id == profile_switch::ID) .expect("a matched profile is offered as a control"); assert_eq!(cap.params.len(), 1); assert_eq!(cap.params[0].kind, ParamKind::Bool); // On, and on is the default — so a photograph nobody has touched // carries nothing in its sidecar and is still corrected. assert_eq!(cap.params[0].value, 1.0); assert_eq!(cap.params[0].default, 1.0); assert!(!cap.params[0].is_modified()); // Ahead of the corrections it drives, so the panel reads top down: // the profile, then what to add to it by hand. let ids: Vec<&str> = g.capabilities().iter().map(|c| c.id.0).collect(); assert_eq!(ids.first(), Some(&profile_switch::ID.0)); } /// TRACES: FR-DEV-3 | FR-DEV-5 /// Declining the profile is an edit, so it travels like one. /// /// The reason it is a parameter at all: capture and apply are the road the /// sidecar, the clipboard and the undo stack all take, and a `bool` on the /// side would have had to be added to each of them by hand. #[test] fn the_switch_survives_a_state_round_trip() { use crate::lens::profile_switch; let mut g = EditGraph::default_chain(); g.set_lens_profile(Some(measured())); g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0); let state = g.state(); // Reopened: the profile is looked up again from the file, and the // stored edit says what to do with it. let mut reopened = EditGraph::default_chain(); reopened.set_lens_profile(Some(measured())); assert_eq!(reopened.set_state(&state), FilmRebake::NotNeeded); assert_eq!( reopened.param(profile_switch::ID, profile_switch::APPLY), Some(0.0), "a declined profile came back applied, so reopening the \ photograph would silently correct it again" ); assert!(!reopened.compose().source.contains("---- warp: ")); } /// TRACES: FR-DEV-3 /// A reset accepts the profile again, and keeps it. #[test] fn resetting_returns_to_the_measured_profile() { use crate::lens::profile_switch; let mut g = EditGraph::default_chain(); g.set_lens_profile(Some(measured())); g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0); g.reset(); assert!(g.lens_profile().is_some(), "the file still names a lens"); assert!(g.lens_profile_applied()); assert!(g.compose().source.contains("---- warp: distortion ----")); } /// A warp is an edit, so `reset` has to reach it. It did not until the /// loop was added: a reset that left the lens corrections standing would /// mean "back to the file as it is" quietly did not mean that. #[test] fn resetting_the_graph_neutralises_the_warps() { use crate::ops::distortion; let mut g = EditGraph::default_chain(); g.set_param(distortion::ID, distortion::AMOUNT, 40.0); g.reset(); assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(0.0)); } /// The warps belong to the geometry key, not the colour one. /// /// A tile cache keyed on geometry holds the *result* of the coordinate /// stage. Moving a distortion slider changes which source pixel every /// output pixel reads, so a cache that did not notice would keep drawing /// the previous correction — visibly, and only where it had already /// cached. #[test] fn a_warp_moves_the_geometry_key_and_leaves_the_others_alone() { use crate::operation::Affects; use crate::ops::distortion; let mut g = EditGraph::default_chain(); let before = g.invalidation(); g.set_param(distortion::ID, distortion::AMOUNT, 40.0); let after = g.invalidation(); assert_ne!( before.of(Affects::Geometry), after.of(Affects::Geometry), "a distortion change must invalidate the geometry stage" ); assert_eq!( before.of(Affects::Colour), after.of(Affects::Colour), "and must not invalidate the colour stage, which it does not touch" ); } #[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 everything that is *not* an operation and so // is absent from `descriptors`: the framing, and the coordinate-domain // lens corrections. All of them have parameters a photographer sets, // so all of them have to reach the panel — and the panel is forbidden // from naming any of them (FR-DEV-3a), which leaves this list as the // only way they can arrive. assert_eq!(caps.len(), g.descriptors().len() + g.warps.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 warp in &g.warps { let id = warp.descriptor().id; assert!( caps.iter().any(|c| c.id == id), "{id} must appear in the capability list, or its correction is \ in the shader with no control anywhere that can reach 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.base_mut().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()); } }