Files
DarkRoom/core/dr-pipeline/src/graph.rs
T
dtourolle 9b6b4942cf The camera-space tap: OutputMode::CameraLinear, composed with no operations
compose_camera_linear composes the fused pass with an empty operation
list, the file's orientation as the baseline, a view rect for the tile,
and a store of rgba32float. On the GPU, render_camera_linear is the only
entry that accepts it: it fills the profile uniforms neutral — unit white
balance, identity matrix, curve off — so what lands in the texture is the
sensor's numbers after the lens warp and nothing else (FR-MRG-2). A third
bind-group layout carries the format, as the linear one does, and the
readback is generalised to any pixel width for the f32 copy.

Thirty-two bits because the composite is written back at the sensor's
scale: a 14-bit sensor has 16 384 steps to white and f16 keeps 2 048 of
them in the top octave.
2026-09-19 15:24:12 +02:00

1900 lines
79 KiB
Rust

//! 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<ParamCapability>,
/// 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<Presentation>,
/// 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<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<Facet>,
}
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<Box<dyn Operation>>,
/// 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<MaskStack>,
/// 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<Film>,
/// TRACES: FR-DEV-8
/// The repairs (`docs/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/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<Box<dyn crate::lens::Warp>>,
/// 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<LensProfile>,
/// 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<String>,
/// 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/<id>.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)
}
/// 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<Arc<OpDescriptor>> {
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<LensProfile>) {
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<Arc<OpDescriptor>> {
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<OpCapability> {
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<Vec<Uniform>> {
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<dyn Operation>` 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<Film>) {
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 {
params: Preset::capture(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.
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<f32> {
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-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)
}
/// 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,
source: (u32, u32),
render: (u32, u32),
) -> crate::detail::ComposedDetail {
self.compose_detail_for(source, render, 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.
/// `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_for(
&self,
source: (u32, u32),
render: (u32, u32),
output: dr_types::ColourSpace,
) -> 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, 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 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.
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);
}
/// 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<String> = 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());
}
}