Files
DarkRoom/core/dr-pipeline/src/graph.rs
T
dtourolleandClaude Opus 5 97479a0512 Hold the repairs a photographer makes, and say which may share a pass
A spot is a disc, a source offset and four numbers, and it lives beside
`ops` for the reason `masks` and `film` do: the operation trait is
ParamId -> f32, and a list of repairs is neither scalar nor fixed.

Two decisions here are not obvious. The id is derived from the position
rather than counted, because two devices editing offline would each mint
`spot3` for different marks and the sidecar merge would then treat two
repairs as one — from the position, two devices that removed the same
piece of dust agree, and two that removed different ones do not. And
every length is in the frame's isotropic units, not a mixture of those
and shorter-edge fractions: one unit for the radius, the feather and the
offset agrees on a landscape frame and on a portrait one, where a mixture
only agrees on the first.

`rounds` is the arithmetic that keeps a source from reading a
destination. Every spot in one pass reads the photograph as it stood
before that pass, so a spot sourcing from an earlier spot's destination
would copy the mark that spot was removing. Grouping is not a pass per
spot — that is sixty-four dispatches for a case that almost never arises
— it is a new round only when the sources actually collide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:50:00 +02:00

1107 lines
44 KiB
Rust

//! The edit graph — an ordered set of operations (ARCH §3.4).
//!
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
//! moment on Android (ARCH §6.10), and recovery is only tractable because
//! everything needed to re-render lives here rather than in GPU memory.
//!
//! Order is data, not code: operations run in the sequence this holds them,
//! so reordering the pipeline needs no code change.
use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
use crate::framing::{CropRect, Framing};
use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation};
use crate::ops;
use crate::spot::SpotSet;
/// 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; `build.rs` refuses an operation that declares none.
pub attributes: &'static [Attribute],
}
/// TRACES: FR-DEV-3a | FR-DEV-3b
/// What one parameter offers.
///
/// [`Self::kind`] is what selects the control: the UI maps each `ParamKind`
/// to a widget appropriate to the current input modality (ARCH §4.3), and
/// never switches on the parameter's identity.
#[derive(Debug, Clone, PartialEq)]
pub struct ParamCapability {
pub id: ParamId,
pub label: LocalizedKey,
pub kind: ParamKind,
pub default: f32,
/// The current setting, so the control opens where the edit actually is.
pub value: f32,
/// Where this parameter sits among its siblings, when the operation's
/// parameters form a grid rather than a list. `None` for the usual case.
pub facet: Option<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: 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,
}
/// 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: MaskStack::new(),
film: None,
spots: SpotSet::new(),
}
}
/// TRACES: FR-DEV-3
/// The default chain with the detail stage's test consumer appended.
///
/// **Not a shipping path.** `detail_probe` is a separable box blur that
/// exists so the neighbourhood stage has something to run (see
/// [`crate::detail::probe`]); it is not declared in `ops/`, has no place
/// in the pipeline order, and is compiled only for tests and behind the
/// `detail-probe` feature.
///
/// It is a constructor rather than a fixture inside one test module
/// because `dr-gpu` needs the same graph: proving the stage works means
/// dispatching it, and dispatching it means composing both halves of the
/// shader from one graph exactly as the interface will.
#[cfg(any(test, feature = "detail-probe"))]
pub fn with_detail_probe() -> Self {
let mut graph = Self::default_chain();
graph
.ops
.push(Box::new(crate::detail::probe::BoxBlur::new()));
graph
}
/// 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
}
pub fn masks_mut(&mut self) -> &mut MaskStack {
&mut self.masks
}
/// The framing — crop, straighten, rotation and flips.
///
/// Reached directly rather than through `set_param` because the crop is a
/// rectangle, and driving one through four independent scalars makes an
/// interactive drag four clamps that can disagree. The parameter route
/// still exists for the sidecar, which has only scalars to work with.
pub fn framing(&self) -> &Framing {
&self.framing
}
pub fn framing_mut(&mut self) -> &mut Framing {
&mut self.framing
}
/// The size this graph renders to, given a source of `(w, h)`.
///
/// Cropping and quarter turns change it, so the caller allocating the
/// output texture must ask rather than assume the source size.
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
self.framing.output_size(width, height)
}
/// Descriptors for every operation, in order.
///
/// Operations only — framing is not one, and is reached through
/// [`Self::framing`] or the capability list. The distinction matters here
/// because this is what the codegen tests count `---- ` shader blocks
/// against, and framing generates a prologue rather than a colour block.
/// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a).
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
self.ops.iter().map(|o| o.descriptor()).collect()
}
/// TRACES: FR-DEV-3a | FR-DEV-3c
/// Everything a UI needs to build its controls.
///
/// **This is the only thing the UI should read.** It must not know that
/// exposure exists, that saturation is implemented with a mix, or that
/// any of this becomes a shader — it walks this list and instantiates a
/// control per entry according to the [`ParamKind`]. A new operation
/// therefore appears in the interface with no UI change at all
/// (FR-DEV-3c), and an operation removed from the chain disappears from
/// it just as automatically.
///
/// Current values are included so the UI has no separate initialisation
/// step, and so reopening an edited image shows where the sliders
/// actually are.
pub fn capabilities(&self) -> Vec<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,
}
});
// Framing last, matching where it sits in the pipeline: the crop is
// decided after the image looks right, not before.
let desc = self.framing.descriptor();
let framing = OpCapability {
id: desc.id,
label: desc.label,
active: self.framing.is_active(),
params: desc
.params
.iter()
.map(|p| ParamCapability {
id: p.id,
label: p.label,
kind: p.kind.clone(),
default: p.default,
value: self.framing.param(p.id),
facet: p.facet,
})
.collect(),
// Framing is not an `Operation`, but it has the same thing to say
// about how it wants drawing: a crop is dragged on the photograph.
// Declaring it here is what lets the frontend skip generating
// sliders for framing *without naming framing* — see
// `Framing::presentation`.
presentation: self.framing.presentation(),
attributes: desc.attributes,
};
ops.chain(std::iter::once(framing)).collect()
}
/// Set a parameter, clamping to the descriptor's declared range.
///
/// Clamping here rather than in each operation means an operation never
/// has to defend against an out-of-range value, and a corrupt sidecar
/// cannot reach a shader.
/// 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()
}
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::framing::ID {
let Some(desc) = self.framing.descriptor().param(param) else {
log::warn!("unknown parameter {param} on {op}; ignoring");
return;
};
self.framing.set_param(param, desc.clamp(value));
return;
}
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
// A sidecar naming an operation this build does not have. The
// rest of the edit must still apply.
log::warn!("unknown operation {op}; ignoring");
return;
};
let clamped = match operation.descriptor().param(param) {
Some(d) => d.clamp(value),
None => {
log::warn!("unknown parameter {param} on {op}; ignoring");
return;
}
};
operation.set_param(param, clamped);
}
/// Read a parameter back.
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
if op == crate::framing::ID {
return self
.framing
.descriptor()
.param(param)
.map(|_| self.framing.param(param));
}
self.ops
.iter()
.find(|o| o.descriptor().id == op)
.map(|o| o.param(param))
}
/// Reset every parameter of every operation, and the framing, to default.
pub fn reset(&mut self) {
for op in &mut self.ops {
for p in op.descriptor().params {
op.set_param(p.id, p.default);
}
}
self.framing.reset();
// Masks go too, and this is why `apply` can be a replacement rather
// than an overlay: a sidecar with no mask blocks means an edit with no
// local adjustments, not an edit that keeps whatever was on screen.
self.masks = MaskStack::new();
// 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);
}
/// 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)
}
/// TRACES: FR-DSP-1
/// How this render relates to the file it stands for.
///
/// `source` is the demosaiced image's size and `render` the size being
/// drawn now. The result describes *the region on screen*, with the crop
/// and the zoom already folded in: cropping to half the frame while the
/// viewport stays the same size genuinely does show twice the detail, and
/// zooming to 1:1 genuinely does make the preview exact. Both fall out of
/// the arithmetic rather than needing a special case.
///
/// Only the detail stage needs this. Every point operation is scale-free
/// — a multiply is a multiply at any resolution — which is why nothing in
/// the pipeline had to know its own size until a kernel arrived.
pub fn render_scale(
&self,
source: (u32, u32),
render: (u32, u32),
) -> crate::detail::RenderScale {
let (fw, fh) = self.framing.output_size(source.0, source.1);
let view = self.framing.view();
// The *viewed* part of the framed image, at source resolution. Zoom
// shrinks the view rect while the render target keeps its size, so
// this is what shrinks and the ratio is what climbs.
let full = (
((fw as f32 * view.width).round() as u32).max(1),
((fh as f32 * view.height).round() as u32).max(1),
);
crate::detail::RenderScale::new(render, full)
}
/// TRACES: FR-DEV-3 | FR-DSP-1
/// Generate the detail stage for this edit at one resolution, to sRGB.
///
/// Empty for every edit with no active neighbourhood operation, which is
/// almost all of them — and in that case [`Self::compose`] emits the
/// single encoded dispatch it always has.
pub fn compose_detail(
&self,
scale: crate::detail::RenderScale,
) -> crate::detail::ComposedDetail {
self.compose_detail_for(scale, dr_types::ColourSpace::Srgb)
}
/// TRACES: FR-EXP-2
/// The detail stage, encoded into a chosen output space.
///
/// The space belongs here as well as on [`Self::compose_for`] because when
/// a detail stage exists it is the *last* pass that performs the output
/// transform — the fused pass stops at linear working values. Composing
/// the two halves for different spaces would encode the edit twice, or
/// not at all.
pub fn compose_detail_for(
&self,
scale: crate::detail::RenderScale,
output: dr_types::ColourSpace,
) -> crate::detail::ComposedDetail {
crate::detail::compose_detail(&self.ops, scale, output)
}
/// TRACES: FR-DEV-3d
/// The per-stage cache keys for the current edit.
///
/// See [`crate::Invalidation`] for what the keys mean and what may be
/// cached against them. In short: geometry covers the framing, colour
/// covers every fused operation and every mask layer, and detail covers
/// the neighbourhood operations — so moving one slider moves exactly one
/// key, and a consumer can tell which stages it has to redo.
pub fn invalidation(&self) -> crate::Invalidation {
use crate::operation::{hash_bytes, hash_op, mix, Affects, FNV_OFFSET};
// Geometry: the framing. Its own structure key covers the shape of the
// coordinate map; the parameters cover the magnitudes, which the
// structure key deliberately omits because they do not recompile a
// shader. Both matter to a cached *result*, so both are here.
let mut geometry = mix(FNV_OFFSET, self.framing.structure_key());
for p in self.framing.descriptor().params {
geometry = hash_bytes(geometry, p.id.0.as_bytes());
geometry = mix(
geometry,
u64::from(crate::operation::canonical_bits(self.framing.param(p.id))),
);
}
// The view rect is not a parameter and not in the structure key — it
// is not an edit (see `Framing::view`). It is still an input to every
// rendered pixel, so a cache that ignored it would show the wrong part
// of the photograph after a scroll.
let view = self.framing.view();
for v in [view.x, view.y, view.width, view.height] {
geometry = mix(geometry, u64::from(crate::operation::canonical_bits(v)));
}
let mut colour = FNV_OFFSET;
let mut detail = FNV_OFFSET;
for op in &self.ops {
let target = if op.affects() == Affects::Detail {
&mut detail
} else {
&mut colour
};
*target = hash_op(*target, op.as_ref());
}
// The mask layers belong to the colour stage: their chains are fused
// into the same dispatch, and a layer's *shape* decides which pixels
// that dispatch treats differently. Both halves are folded in.
for layer in self.masks.layers() {
colour = hash_bytes(colour, layer.id.as_bytes());
// The source through its `Debug`, deliberately. A gradient's
// centre, a region's id list and a subject's signature are all
// part of where the layer applies, and matching on the variants
// here would be a second copy of `MaskSource`'s shape that falls
// out of step the first time a variant gains a field — silently,
// and showing as a mask that stops updating. `Debug` cannot fall
// out of step, because it is derived from the definition itself.
colour = hash_bytes(colour, format!("{:?}", layer.source).as_bytes());
colour = mix(colour, u64::from(layer.enabled));
colour = mix(colour, u64::from(layer.invert));
colour = hash_bytes(colour, layer.falloff.name().as_bytes());
for v in [layer.opacity, layer.feather, layer.morph_radius] {
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
}
for (op_id, param_id, value) in layer.params() {
colour = hash_bytes(colour, op_id.as_bytes());
colour = hash_bytes(colour, param_id.as_bytes());
colour = mix(colour, u64::from(crate::operation::canonical_bits(value)));
}
}
// 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);
}
#[test]
fn capabilities_describe_every_operation_and_parameter() {
// The UI builds its whole panel from this. Anything missing here is
// something the UI would have to hardcode.
let g = EditGraph::default_chain();
let caps = g.capabilities();
// Every operation, plus framing — which is not an operation and so
// is absent from `descriptors`, but must still reach the panel.
assert_eq!(caps.len(), g.descriptors().len() + 1);
assert!(
caps.iter().any(|c| c.id == crate::framing::ID),
"framing must appear in the capability list, or the UI cannot \
build a crop control without naming it"
);
for cap in &caps {
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
for p in &cap.params {
// A control cannot be built without a range.
match &p.kind {
ParamKind::Scalar { min, max, .. } => {
assert!(min < max, "{}.{} has an empty range", cap.id, p.id);
assert!(
(*min..=*max).contains(&p.default),
"{}.{} default is outside its range",
cap.id,
p.id
);
}
ParamKind::Bool => {}
ParamKind::Enum { variants } => {
// An empty list is a control with nothing to pick, and
// a one-entry list is a control that cannot be
// changed — both are declaration mistakes rather than
// states a UI should try to render.
assert!(
variants.len() > 1,
"{}.{} offers fewer than two choices",
cap.id,
p.id
);
assert!(
p.default >= 0.0 && p.default < variants.len() as f32,
"{}.{} defaults to a variant that does not exist",
cap.id,
p.id
);
}
}
}
}
}
#[test]
fn capabilities_report_current_values_not_just_defaults() {
// So reopening an edited image shows the sliders where the edit left
// them, with no separate initialisation path in the UI.
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
let cap = g
.capabilities()
.into_iter()
.find(|c| c.id == exposure::ID)
.expect("exposure is in the chain");
let p = &cap.params[0];
assert_eq!(p.value, 1.25);
assert_eq!(p.default, 0.0);
assert!(p.is_modified());
assert!(cap.active);
}
#[test]
fn a_fresh_graph_reports_nothing_modified() {
for cap in EditGraph::default_chain().capabilities() {
assert!(!cap.active, "{} should start inactive", cap.id);
for p in &cap.params {
assert!(
!p.is_modified(),
"{}.{} should start at default",
cap.id,
p.id
);
}
}
}
#[test]
fn capabilities_survive_a_round_trip_through_set_param() {
// The UI reads a capability, writes the value back, and must get the
// same thing out — no hidden scaling between the two.
//
// Written at the parameter's declared precision, because that is what
// the UI can actually produce: a control declaring 0 decimals emits
// whole numbers, and a stage free to quantise to them is behaving
// correctly rather than losing the value.
let mut g = EditGraph::default_chain();
for cap in g.capabilities() {
for p in &cap.params {
if let ParamKind::Scalar { max, precision, .. } = p.kind {
let step = 10f32.powi(i32::from(precision));
let target = (max * 0.5 * step).round() / step;
g.set_param(cap.id, p.id, target);
assert_eq!(
g.param(cap.id, p.id),
Some(target),
"{}.{} did not round-trip",
cap.id,
p.id
);
}
}
}
}
#[test]
fn adding_an_operation_needs_no_ui_change() {
// FR-DEV-3c, asserted structurally: everything a control needs is
// reachable from the capability list, so a new operation appears
// without the UI naming it. If this test needs editing to add an
// operation, the abstraction has leaked.
let g = EditGraph::default_chain();
let rendered: Vec<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.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());
}
}