Files
DarkRoom/core/dr-pipeline/src/graph.rs
T
dtourolle c045702a47 Show the photographer the mask they are shaping
Nobody can refine an edge they are not being shown. The only thing drawn on
the canvas was the region overlay — a false-coloured picture of what the model
*detected* — which knows nothing of a layer's feather, its falloff, its
morphology, its invert or its opacity, and nothing at all about a gradient, a
range or a stroke. Every control added for mask editing therefore acted on
something invisible, which is why the whole feature reads as absent rather
than as unfinished.

A layer's finished mask now draws over the photograph in one of three styles:
a tint for whether the right thing is selected, an alpha for where the edge
is, an outline for whether that edge is registered against the detail the
other two hide.

The hard part is not the shader. A selection with no adjustment on it changes
no pixel, so it is not active, so it holds no slice of the mask array and is
never rasterised — and that is exactly the layer somebody wants to look at,
for the whole of the time between choosing a subject and deciding what to do
to it. So `MaskStack::rendered` is `active()` plus the layer being looked at,
and the rasteriser, the composer and the distance-field builder all index by
position in it. Which is also why the design's "two uniforms, no recompile" is
not available: a uniform can select a slot, it cannot conjure one.

The reveal is never on the graph. It reaches the pipeline as an argument to
`compose_revealing`, and `compose_for` — which the exporter, the thumbnail and
the neutral probe all call — has no way to ask for one. A flag on the graph
would have been shorter, would have type-checked, and would have been one
forgotten reset away from a red tint baked into an exported file.

And the tools that shape a mask now arm. `Masking.tool` is an `in` property
only Rust may write, and the handler wrote nothing back, so the strip reported
"Select" however many times Paint was pressed and the paint area was never
enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no
control in the application.

The region overlay stands down while a mask is being shown, and its button now
says what it hides: two overlays that look alike and mean different things is
worse than either.
2026-09-10 20:28:06 +02:00

1890 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-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));
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());
}
}