Put the coordinate-domain lens corrections into the graph

`lens.rs` has held a `Warp` trait, a composer and two implementations —
distortion and lateral chromatic aberration — since they were written, and
`compose_warps` was called by nothing outside its own tests. The corrections
existed, were correct, and never touched a photograph.

`EditGraph` now holds them, and `compose_full` emits them between the framing
prologue and the fetch. Distortion first, then CA: each warp receives the
position the previous one produced, and lateral CA is a magnification about
the optical axis of the *undistorted* frame, so measured on a barrel-distorted
one it would be fitted to a radius no profile describes.

They reach the panel the way framing already does — through `capabilities`.
That was the one open question and existing practice answered it: framing is
also not an `Operation`, also has parameters a photographer sets, and also
arrives through that list. Because `Preset::capture` walks the same list, the
sidecar, the clipboard and the undo stack carry a warp's parameters with
nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a).

`state()` destructures `EditGraph` field by field precisely so that a new
field cannot be forgotten, and it was not.

Chromatic aberration is the only thing that samples per channel, and
`splits_channels` is what keeps everything else from paying for it. Red and
blue are fetched from positions green is not — green is the reference and
never moves, so a wrong correction still leaves one channel sharp rather than
softening all three. With no CA in the chain the single-fetch path is emitted
instead.

The interpolating sampler is now chosen by framing *or* an active warp. Asking
framing alone would have nearest-neighboured a distortion correction on an
unstraightened frame, and that aliasing reads as a bad profile rather than as
a missing filter.

The warps go in the geometry invalidation key rather than the colour one: they
decide which source pixel a colour is read from, so a tile cached across a
distortion change would keep drawing the previous correction. The pipeline
cache needs nothing new — `hash_source` already covers the generated body, and
uniform values never enter it, so arming a warp recompiles and dragging it
does not. Both are asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 15:03:16 +02:00
co-authored by Claude Opus 5
parent e17b909d41
commit a1165ef182
10 changed files with 477 additions and 30 deletions
+210 -5
View File
@@ -120,6 +120,20 @@ pub struct EditGraph {
/// *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>>,
}
/// TRACES: FR-DEV-3f
@@ -164,6 +178,16 @@ impl EditGraph {
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()),
],
}
}
@@ -248,6 +272,18 @@ impl EditGraph {
self.ops.iter().map(|o| o.descriptor()).collect()
}
/// 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.
///
@@ -286,6 +322,39 @@ impl EditGraph {
}
});
// 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();
@@ -314,7 +383,7 @@ impl EditGraph {
attributes: desc.attributes.clone(),
};
ops.chain(std::iter::once(framing)).collect()
warps.chain(ops).chain(std::iter::once(framing)).collect()
}
/// Set a parameter, clamping to the descriptor's declared range.
@@ -362,6 +431,11 @@ impl EditGraph {
// 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: _,
masks,
film,
spots,
@@ -440,6 +514,20 @@ impl EditGraph {
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.
@@ -465,6 +553,9 @@ impl EditGraph {
.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)
@@ -478,6 +569,11 @@ impl EditGraph {
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
@@ -542,7 +638,14 @@ impl EditGraph {
/// 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)
compose_full(
&self.ops,
&self.framing,
output,
&self.masks,
&self.spots,
&self.warps,
)
}
/// TRACES: FR-DSP-1
@@ -637,6 +740,22 @@ impl EditGraph {
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
@@ -823,20 +942,106 @@ mod tests {
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 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 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);
// 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);