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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user