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
+1
View File
@@ -335,6 +335,7 @@ fn render_stack(
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
adjust
.render_masked(source, &shader, ow, oh, Some(array))
+16 -2
View File
@@ -1845,7 +1845,21 @@ mod tests {
fused_blocks += usize::from(point);
}
// The count the loop above accumulated, plus framing — which emits a
// The lens corrections, which are the third kind of block. They are
// not in `descriptors` — they rewrite coordinates rather than
// transform a colour, so they are not operations — and they run ahead
// of the fetch rather than in either stage the loop above sorts into.
let mut warp_blocks = 0;
for desc in g.warp_descriptors() {
let id = desc.id.0;
assert!(
shader.source.contains(&format!("---- warp: {id} ----")),
"{id} was armed above and did not reach the shader"
);
warp_blocks += 1;
}
// The counts the loops above accumulated, plus framing — which emits a
// stage of its own rather than an operation block and is not in
// `descriptors`. Asserted as well as the per-operation exclusive-or
// because the two catch different faults: the XOR catches an operation
@@ -1853,7 +1867,7 @@ mod tests {
// in the chain asked for.
assert_eq!(
shader.source.matches("---- ").count(),
fused_blocks + 1,
fused_blocks + warp_blocks + 1,
"the fused shader carries a block nothing in the chain asked for"
);
assert!(
+1
View File
@@ -72,6 +72,7 @@ fn render_at(
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
+3
View File
@@ -90,6 +90,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut adjust = AdjustPass::new(ctx);
adjust
@@ -108,6 +109,7 @@ fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, out, out).expect("render");
@@ -230,6 +232,7 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut adjust = AdjustPass::new(ctx);
adjust