Add folder scan with format selection; validate A3 on a real library

Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.

  dr-types::FormatFilter  the tick-box selection, seeing through VFS
                          placeholder suffixes so a dehydrated CR2 still
                          matches as a CR2
  dr-sync::scan           recursive walk, Depth:1 per directory, pruning
                          unchanged subtrees where the backend propagates
                          directory ETags

Verified against nextcloud.tourolle.paris (34.0.2) on a real library:

  browse root      32 entries, 98ms
  scan PhotosRaw   17,185 RAW files in 334 directories, 34.1s
                   (7,836 CR2 + 9,349 DNG)
  range read       262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
                   and enough to read "Canon EOS 6D | ISO 100"

That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.

Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.

Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
This commit is contained in:
2026-08-09 12:22:31 +02:00
parent fbadf9afc8
commit c8bb08e661
29 changed files with 7193 additions and 234 deletions
+31
View File
@@ -113,6 +113,37 @@ impl ParamDescriptor {
}
}
/// A toggle. Neutral when off, so the reset contract still holds.
pub const fn switch(id: &'static str, label: &'static str) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Bool,
default: 0.0,
}
}
/// A 0…1 fraction — a proportion of something, rather than an amount.
///
/// Its own constructor because the crop rect needs four of them and the
/// default differs per edge: an origin starts at 0 and an extent at 1.
/// Precision of 4 because at 6000px a step of 0.0001 is under a pixel,
/// and a coarser one would make a crop edge unplaceable.
pub const fn fraction(id: &'static str, label: &'static str, default: f32) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min: 0.0,
max: 1.0,
scale: Scale::Linear,
unit: Unit::None,
precision: 4,
},
default,
}
}
/// A general scalar with an explicit range and default.
//
// Eight arguments, and a builder would be the usual answer — but this has
+943
View File
@@ -0,0 +1,943 @@
//! Framing — crop, straighten, rotate, flip (FR-DEV-3, ARCH §5.2).
//!
//! # Why this is not an `Operation`, and not a `Warp` either
//!
//! An [`crate::operation::Operation`] is a function from colour to colour. By
//! the time one runs, the colour has been sampled and the question framing
//! asks — *which* source pixel does this output pixel come from — has already
//! been answered. And a crop changes the output's dimensions and aspect
//! ratio, which no colour fragment can express.
//!
//! [`crate::warp::Warp`] is closer: it also rewrites coordinates before the
//! fetch. But a warp is a *correction to the optics* — distortion and CA are
//! properties of the lens, defined about the optical axis, over the whole
//! frame the lens projected. Framing is a decision about *composition*, made
//! afterwards. The order matters and is not a preference:
//!
//! ```text
//! output pixel → framing → warp (lens) → sample → colour ops → output
//! ```
//!
//! Reading forward, the lens is corrected on the full frame and the crop
//! then selects from the corrected result. Correcting distortion on an
//! already-cropped frame would place the optical centre in the wrong spot and
//! bend the image about a point the lens never saw.
//!
//! So framing runs **first** in the coordinate chain, and hands the warp
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
//! corner. Neither stage needs to know the other exists.
//!
//! # Why sampling changes with the angle
//!
//! At 90° steps and flips, output pixels land exactly on source pixels, so
//! the map is a permutation and an integer `textureLoad` is both correct and
//! lossless. At any other angle it is not, and nearest-neighbour sampling
//! makes a straightened horizon visibly stair-step — the commonest use of
//! this stage, and where the artefact is most obvious. Free angles therefore
//! need interpolation, which is what [`Framing::needs_interpolation`] tells
//! the composer. Paying for it only when the angle demands it keeps the
//! common case exact rather than merely close.
use std::f32::consts::PI;
use std::fmt::Write as _;
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
use crate::operation::Affects;
pub const ID: OpId = OpId("framing");
pub const ANGLE: ParamId = ParamId("angle");
pub const ROTATION: ParamId = ParamId("rotation");
pub const FLIP_H: ParamId = ParamId("flip_h");
pub const FLIP_V: ParamId = ParamId("flip_v");
pub const CROP_X: ParamId = ParamId("crop_x");
pub const CROP_Y: ParamId = ParamId("crop_y");
pub const CROP_W: ParamId = ParamId("crop_w");
pub const CROP_H: ParamId = ParamId("crop_h");
/// Widest straightening the control offers, in degrees either way.
///
/// Straightening a horizon is a small correction; a gross reorientation is
/// what the 90° steps are for. Bounding it keeps the slider's travel where
/// the edits actually are.
pub const MAX_STRAIGHTEN: f32 = 45.0;
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.framing"),
params: &[
// Straightening. Degrees rather than a normalised amount because a
// photographer reading "-1.4°" off a horizon knows what it means.
ParamDescriptor::scalar(
"angle",
"param.angle",
-MAX_STRAIGHTEN,
MAX_STRAIGHTEN,
0.0,
Unit::None,
Scale::Linear,
2,
),
// Quarter turns, 0..3. Separate from `angle` because these are exact
// and lossless, and because reorienting a frame is a different
// gesture from nudging a horizon.
ParamDescriptor::scalar(
"rotation",
"param.rotation",
0.0,
3.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::switch("flip_h", "param.flip_h"),
ParamDescriptor::switch("flip_v", "param.flip_v"),
// The crop rect, in fractions of the source. Normalised rather than
// in pixels so a crop survives being applied to a proxy, a full
// resolution render, or an export at another size — the same reason
// the viewport renders at display resolution (FR-DSP-1).
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
],
};
/// A normalised crop rectangle, in fractions of the source image.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CropRect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Default for CropRect {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
}
}
}
impl CropRect {
/// Smallest crop the rect may be reduced to, as a fraction of the source.
///
/// A zero-extent crop produces a zero-sized output texture, which is a
/// device error rather than a visibly silly image. Bounding it here means
/// no caller has to defend against it.
pub const MIN_EXTENT: f32 = 0.01;
/// Whether this rect selects the whole image.
pub fn is_full(&self) -> bool {
self.x == 0.0 && self.y == 0.0 && self.width == 1.0 && self.height == 1.0
}
/// Clamp into the unit square, keeping the rect non-degenerate.
///
/// The origin is clamped first and the extent fitted to what remains, so
/// a rect dragged past an edge slides rather than inverting.
pub fn normalised(self) -> Self {
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
let width = finite(self.width, 1.0).clamp(Self::MIN_EXTENT, 1.0 - x);
let height = finite(self.height, 1.0).clamp(Self::MIN_EXTENT, 1.0 - y);
Self {
x,
y,
width,
height,
}
}
}
/// Replace a non-finite value with a fallback.
///
/// A NaN reaching the crop rect would propagate into the output *dimensions*,
/// not merely the pixels — `NaN as u32` is 0, and a zero-sized texture is a
/// device error. The same defence as `ParamDescriptor::clamp`, one level up.
fn finite(v: f32, fallback: f32) -> f32 {
if v.is_finite() {
v
} else {
fallback
}
}
/// Crop, straighten, rotation and flips for one image.
///
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
/// device is recovered by re-composing rather than by re-deriving the edit
/// (ARCH §6.10).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Framing {
/// Straightening, in degrees. Positive rotates the image clockwise.
angle: f32,
/// Quarter turns clockwise, 0..=3.
quarter_turns: u8,
flip_h: bool,
flip_v: bool,
crop: CropRect,
}
impl Default for Framing {
fn default() -> Self {
Self {
angle: 0.0,
quarter_turns: 0,
flip_h: false,
flip_v: false,
crop: CropRect::default(),
}
}
}
impl Framing {
pub fn new() -> Self {
Self::default()
}
pub fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
/// What this stage affects, for invalidation scoping (FR-DEV-3d).
pub fn affects(&self) -> Affects {
Affects::Geometry
}
pub fn crop(&self) -> CropRect {
self.crop
}
pub fn set_crop(&mut self, rect: CropRect) {
self.crop = rect.normalised();
}
pub fn angle(&self) -> f32 {
self.angle
}
pub fn quarter_turns(&self) -> u8 {
self.quarter_turns
}
pub fn flips(&self) -> (bool, bool) {
(self.flip_h, self.flip_v)
}
/// Add quarter turns, wrapping. The rotate-left/right buttons.
pub fn rotate_quarters(&mut self, turns: i32) {
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
}
/// Whether this stage currently changes the image.
///
/// The same contract the operations honour: neutral framing contributes
/// nothing to the generated shader, so an uncropped image reads its
/// pixels through the identity map exactly as it did before this existed.
pub fn is_active(&self) -> bool {
self.angle != 0.0
|| self.quarter_turns != 0
|| self.flip_h
|| self.flip_v
|| !self.crop.is_full()
}
/// Whether the axes are swapped — a 90° or 270° turn.
fn swaps_axes(&self) -> bool {
self.quarter_turns % 2 == 1
}
/// Whether the map puts output pixels between source pixels.
///
/// False for quarter turns and flips, which are permutations with an
/// exact answer. True once a free angle is involved. The composer reads
/// this to decide between an integer load and a filtered sample — and a
/// warp being active forces interpolation regardless, which is the
/// composer's call to make rather than this stage's.
pub fn needs_interpolation(&self) -> bool {
self.angle != 0.0
}
pub fn set_param(&mut self, id: ParamId, value: f32) {
match id {
ANGLE => self.angle = finite(value, 0.0),
// Descriptor-clamped to 0..3, so the cast cannot wrap.
ROTATION => self.quarter_turns = finite(value, 0.0).round().clamp(0.0, 3.0) as u8,
FLIP_H => self.flip_h = value != 0.0,
FLIP_V => self.flip_v = value != 0.0,
CROP_X => {
self.crop = CropRect {
x: value,
..self.crop
}
.normalised()
}
CROP_Y => {
self.crop = CropRect {
y: value,
..self.crop
}
.normalised()
}
CROP_W => {
self.crop = CropRect {
width: value,
..self.crop
}
.normalised()
}
CROP_H => {
self.crop = CropRect {
height: value,
..self.crop
}
.normalised()
}
_ => log::warn!("framing: unknown parameter {id}"),
}
}
pub fn param(&self, id: ParamId) -> f32 {
match id {
ANGLE => self.angle,
ROTATION => f32::from(self.quarter_turns),
FLIP_H => f32::from(u8::from(self.flip_h)),
FLIP_V => f32::from(u8::from(self.flip_v)),
CROP_X => self.crop.x,
CROP_Y => self.crop.y,
CROP_W => self.crop.width,
CROP_H => self.crop.height,
_ => 0.0,
}
}
pub fn reset(&mut self) {
*self = Self::default();
}
/// The output size this framing produces from a source of `(w, h)`.
///
/// The rendered aspect ratio follows from here, which is why this is the
/// one piece of framing both the UI and the GPU pass need before any
/// pixel is shaded: the output texture is allocated from it.
///
/// A free angle does **not** change the output size. The rotated image is
/// sampled into the crop rect as it stands, so straightening a horizon
/// leaves the frame where the user put it and may pull in undefined area
/// at the corners — see [`Self::max_inscribed_crop`] for the rect that
/// avoids that.
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
let (w, h) = if self.swaps_axes() {
(height, width)
} else {
(width, height)
};
// Round rather than truncate: half of a 101px axis should be 51, and
// truncation biases every crop smaller.
let cw = ((w as f32 * self.crop.width).round() as u32).max(1);
let ch = ((h as f32 * self.crop.height).round() as u32).max(1);
(cw, ch)
}
/// The largest centred crop, at the current angle, containing no
/// undefined area.
///
/// Rotating a rectangle inside its own bounds exposes the corners: there
/// is no source pixel there, and the shader renders it black. This is the
/// rect that avoids it — what a "straighten and auto-crop" gesture would
/// apply, and what the crop overlay should offer as its bound.
///
/// The standard largest-inscribed-rectangle result for a rotated
/// rectangle of the same aspect ratio.
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
if self.angle == 0.0 || width == 0 || height == 0 {
return CropRect::default();
}
let (w, h) = if self.swaps_axes() {
(height as f32, width as f32)
} else {
(width as f32, height as f32)
};
let a = (self.angle * PI / 180.0).abs();
let (sin, cos) = (a.sin(), a.cos());
// Longer and shorter side, so the two cases below stay symmetric.
let (long, short) = if w >= h { (w, h) } else { (h, w) };
let (bw, bh) = if short <= 2.0 * sin * cos * long || (sin - cos).abs() < 1e-6 {
// Half-constrained: the shorter side alone limits the rectangle.
let half = 0.5 * short;
if w >= h {
(half / sin, half / cos)
} else {
(half / cos, half / sin)
}
} else {
// Fully constrained by both sides.
let cos2 = cos * cos - sin * sin;
((w * cos - h * sin) / cos2, (h * cos - w * sin) / cos2)
};
// Back to fractions of the (possibly axis-swapped) frame, centred.
let fw = (bw / w).clamp(CropRect::MIN_EXTENT, 1.0);
let fh = (bh / h).clamp(CropRect::MIN_EXTENT, 1.0);
CropRect {
x: (1.0 - fw) * 0.5,
y: (1.0 - fh) * 0.5,
width: fw,
height: fh,
}
.normalised()
}
/// Uniform values the generated prologue reads.
///
/// A fixed-size block in a fixed slot, like the camera matrix: the
/// prologue is emitted whether or not any operation is active, so its
/// uniforms cannot be positioned by the op loop.
///
/// The angle reaches the shader as sin/cos rather than degrees — a trig
/// call per pixel would recover a value constant across the dispatch.
pub fn uniforms(&self) -> [f32; FRAMING_UNIFORM_FIELDS] {
let rad = self.angle * PI / 180.0;
[
self.crop.x,
self.crop.y,
self.crop.width,
self.crop.height,
rad.sin(),
rad.cos(),
0.0,
0.0,
]
}
/// The WGSL mapping an output pixel to a **normalised centred** source
/// position, ready for the warp chain.
///
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
/// exactly the space [`crate::warp`] documents, so lens correction
/// composes on top of this without either stage naming the other.
///
/// `aspect` is left in scope alongside it, since the warp chain and the
/// sampler both need it to return to texture coordinates.
pub fn wgsl_prologue(&self) -> String {
let mut s = String::new();
s.push_str(
" // ---- framing ----
// Output pixel -> source position, in the normalised centred space the
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
// corner. Working here rather than in pixels is what makes the map
// independent of the resolution being rendered at.
let src_dims = textureDimensions(source);
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
",
);
if !self.is_active() {
// Neutral framing still has to produce `p`, since the warp chain
// and the sampler read it either way. It is only the crop,
// rotation and flip steps that vanish.
s.push_str(
"
// Framing is neutral: the whole frame, unrotated.
var p = (uv - vec2<f32>(0.5)) * aspect;
",
);
return s;
}
s.push_str(
"
// Into the crop rect.
uv = u.crop_rect.xy + uv * u.crop_rect.zw;
var p = (uv - vec2<f32>(0.5)) * aspect;
",
);
if self.angle != 0.0 {
// Done in the aspect-corrected space, which is the whole reason
// `p` is scaled by `aspect` above: a rotation applied to raw 0..1
// coordinates on a non-square image shears it rather than
// turning it, and that reads as a rendering fault.
s.push_str(
"
// Straighten, about the frame centre.
p = vec2<f32>(
p.x * u.framing_angle.y - p.y * u.framing_angle.x,
p.x * u.framing_angle.x + p.y * u.framing_angle.y,
);
",
);
}
if self.quarter_turns != 0 {
// An exact coordinate permutation rather than a rotation through
// the matrix above, which would resample a transform that has an
// exact answer. Applied to `p`, so the aspect scaling has to be
// undone and reapplied across the swap.
let permutation = match self.quarter_turns {
1 => " p = vec2<f32>(p.y * aspect.x, -p.x / aspect.x);",
2 => " p = -p;",
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / aspect.x);",
};
let _ = write!(
s,
"
// {}° clockwise — an exact permutation, so nothing is resampled.
{permutation}
",
u32::from(self.quarter_turns) * 90
);
}
if self.flip_h {
s.push_str(" p.x = -p.x;\n");
}
if self.flip_v {
s.push_str(" p.y = -p.y;\n");
}
s
}
/// Identifies this framing's *structure* — which branches the prologue
/// generates, not the values it reads.
///
/// Deliberately coarse, for the reason the operation hash is: dragging
/// the crop handles or the straighten slider must reuse the compiled
/// pipeline and upload uniforms only. Only the presence of each
/// transform, never its magnitude, may enter this.
pub fn structure_key(&self) -> u64 {
u64::from(!self.crop.is_full())
| u64::from(self.angle != 0.0) << 1
| u64::from(self.flip_h) << 2
| u64::from(self.flip_v) << 3
| u64::from(self.quarter_turns) << 4
}
}
/// Floats the framing block occupies in the generated uniform struct.
///
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding.
pub const FRAMING_UNIFORM_FIELDS: usize = 8;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_framing_is_neutral() {
// The invariant behind "opening an image shows the image".
let f = Framing::new();
assert!(!f.is_active());
assert!(!f.needs_interpolation());
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
}
#[test]
fn neutral_framing_still_produces_a_position_for_the_warp_chain() {
// The prologue always defines `p` and `aspect`, active or not — the
// warp chain and the sampler read them either way, so a neutral
// framing that skipped them would fail to compile rather than
// rendering an unframed image.
let src = Framing::new().wgsl_prologue();
assert!(src.contains("var p ="), "{src}");
assert!(src.contains("let aspect ="), "{src}");
// ...but none of the transform steps.
assert!(!src.contains("crop_rect"));
assert!(!src.contains("framing_angle"));
}
#[test]
fn an_active_framing_reads_the_crop_rect() {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
assert!(f.wgsl_prologue().contains("u.crop_rect"));
}
#[test]
fn cropping_changes_the_output_size() {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
assert!(f.is_active());
assert_eq!(f.output_size(1000, 800), (500, 400));
}
#[test]
fn a_quarter_turn_swaps_the_output_axes() {
// What makes a landscape frame come out portrait: the output is
// genuinely taller than it is wide.
let mut f = Framing::new();
f.rotate_quarters(1);
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
f.rotate_quarters(1);
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
}
#[test]
fn crop_applies_within_the_rotated_frame() {
// Half of a rotated frame must be half of the *rotated* dimensions,
// or a crop drawn on screen after a rotation lands somewhere else.
let mut f = Framing::new();
f.rotate_quarters(1);
f.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 1.0,
});
assert_eq!(f.output_size(6000, 4000), (2000, 6000));
}
#[test]
fn quarter_turns_wrap_in_both_directions() {
let mut f = Framing::new();
f.rotate_quarters(-1);
assert_eq!(f.quarter_turns(), 3);
f.rotate_quarters(1);
assert_eq!(f.quarter_turns(), 0);
f.rotate_quarters(7);
assert_eq!(f.quarter_turns(), 3);
}
#[test]
fn a_crop_cannot_be_driven_degenerate() {
// A zero-extent crop produces a zero-sized texture, which is a device
// error rather than a visibly silly image.
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.5,
y: 0.5,
width: 0.0,
height: 0.0,
});
let (w, h) = f.output_size(1000, 1000);
assert!(w >= 1 && h >= 1);
assert!(f.crop().width >= CropRect::MIN_EXTENT);
}
#[test]
fn a_crop_pushed_past_the_edge_stays_inside() {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.8,
y: 0.9,
width: 0.5,
height: 0.5,
});
let c = f.crop();
assert!(c.x + c.width <= 1.0 + 1e-6, "{c:?} extends past the edge");
assert!(c.y + c.height <= 1.0 + 1e-6, "{c:?} extends past the edge");
}
#[test]
fn a_nan_crop_falls_back_rather_than_producing_a_zero_texture() {
// Worse than a wrong image: `NaN as u32` is 0, and a zero-sized
// texture is a device error.
let mut f = Framing::new();
f.set_param(CROP_W, f32::NAN);
f.set_param(CROP_X, f32::INFINITY);
let (w, h) = f.output_size(1000, 1000);
assert!(w >= 1 && h >= 1);
assert!(f.crop().width.is_finite() && f.crop().x.is_finite());
}
#[test]
fn output_size_rounds_rather_than_truncating() {
// Truncation biases every crop smaller; half of 101 should be 51.
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 0.5,
});
assert_eq!(f.output_size(101, 101), (51, 51));
}
#[test]
fn quarter_turns_and_flips_need_no_interpolation() {
// Why 90° steps are handled apart from the free angle: they have an
// exact answer and must not be resampled.
let mut f = Framing::new();
f.rotate_quarters(1);
f.set_param(FLIP_H, 1.0);
assert!(f.is_active());
assert!(!f.needs_interpolation());
}
#[test]
fn a_free_angle_needs_interpolation() {
let mut f = Framing::new();
f.set_param(ANGLE, 1.5);
assert!(f.needs_interpolation());
assert!(f.wgsl_prologue().contains("u.framing_angle"));
}
#[test]
fn a_quarter_turn_corrects_for_aspect_across_the_swap() {
// `p` is scaled by the source aspect, so a permutation that exchanges
// the axes has to undo and reapply it. Without that a 90° turn on a
// 3:2 frame comes out stretched.
let mut f = Framing::new();
f.rotate_quarters(1);
assert!(f.wgsl_prologue().contains("aspect.x"));
}
#[test]
fn the_structure_key_ignores_magnitudes() {
// What the pipeline cache depends on: dragging the straighten slider
// or the crop handles must not recompile.
let mut a = Framing::new();
a.set_param(ANGLE, 1.0);
let mut b = Framing::new();
b.set_param(ANGLE, 4.0);
assert_eq!(a.structure_key(), b.structure_key());
assert_eq!(a.wgsl_prologue(), b.wgsl_prologue());
assert_ne!(a.uniforms(), b.uniforms());
let mut c = Framing::new();
c.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
let mut d = Framing::new();
d.set_crop(CropRect {
x: 0.2,
y: 0.2,
width: 0.4,
height: 0.4,
});
assert_eq!(c.structure_key(), d.structure_key());
assert_eq!(c.wgsl_prologue(), d.wgsl_prologue());
}
#[test]
fn different_transforms_take_different_structure_keys() {
// The other half of the cache contract: framing that generates
// different code must not reuse another's pipeline.
let mut seen = std::collections::BTreeSet::new();
seen.insert(Framing::new().structure_key());
let mut cropped = Framing::new();
cropped.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.5,
height: 0.5,
});
assert!(seen.insert(cropped.structure_key()));
let mut angled = Framing::new();
angled.set_param(ANGLE, 2.0);
assert!(seen.insert(angled.structure_key()));
let mut flipped = Framing::new();
flipped.set_param(FLIP_H, 1.0);
assert!(seen.insert(flipped.structure_key()));
for turns in 1..=3 {
let mut f = Framing::new();
f.rotate_quarters(turns);
assert!(seen.insert(f.structure_key()), "{turns} quarter turns");
}
}
#[test]
fn parameters_round_trip() {
let mut f = Framing::new();
for (id, v) in [
(ANGLE, 2.5),
(ROTATION, 2.0),
(FLIP_H, 1.0),
(FLIP_V, 1.0),
(CROP_X, 0.1),
(CROP_Y, 0.2),
(CROP_W, 0.5),
(CROP_H, 0.4),
] {
f.set_param(id, v);
assert_eq!(f.param(id), v, "{id} did not round-trip");
}
}
#[test]
fn every_default_leaves_the_stage_neutral() {
// The same contract the operations honour, checked against the
// descriptor rather than a literal.
let mut f = Framing::new();
for p in DESCRIPTOR.params {
f.set_param(p.id, p.default);
}
assert!(!f.is_active(), "descriptor defaults must be neutral");
}
#[test]
fn every_default_is_within_its_declared_range() {
for p in DESCRIPTOR.params {
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
}
}
#[test]
fn no_parameter_is_declared_twice() {
let mut ids: Vec<&str> = DESCRIPTOR.params.iter().map(|p| p.id.0).collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(before, ids.len(), "framing has a duplicate parameter");
}
#[test]
fn reset_returns_to_neutral() {
let mut f = Framing::new();
f.set_param(ANGLE, 3.0);
f.rotate_quarters(1);
f.set_crop(CropRect {
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.3,
});
assert!(f.is_active());
f.reset();
assert!(!f.is_active());
assert_eq!(f.wgsl_prologue(), Framing::new().wgsl_prologue());
}
#[test]
fn uniforms_carry_the_angle_as_sin_and_cos() {
// The shader never sees degrees: converting here keeps a trig call
// out of every pixel.
let mut f = Framing::new();
f.set_param(ANGLE, 90.0);
let u = f.uniforms();
assert!(
(u[4] - 1.0).abs() < 1e-6,
"sin(90°) should be 1, got {}",
u[4]
);
assert!(u[5].abs() < 1e-6, "cos(90°) should be 0, got {}", u[5]);
}
#[test]
fn uniforms_are_always_finite() {
// One NaN in the uniform block blanks every pixel.
let mut f = Framing::new();
f.set_param(ANGLE, f32::NAN);
f.set_param(CROP_W, f32::NAN);
assert!(
f.uniforms().iter().all(|v| v.is_finite()),
"{:?}",
f.uniforms()
);
}
#[test]
fn the_uniform_block_is_vec4_aligned() {
// Emitted as whole `vec4`s; a size not divisible by four would
// misalign every operation uniform that follows it.
assert_eq!(FRAMING_UNIFORM_FIELDS % 4, 0);
assert_eq!(Framing::new().uniforms().len(), FRAMING_UNIFORM_FIELDS);
}
#[test]
fn the_inscribed_crop_of_an_unrotated_image_is_the_whole_frame() {
assert!(Framing::new().max_inscribed_crop(6000, 4000).is_full());
}
#[test]
fn the_inscribed_crop_shrinks_as_the_angle_grows() {
// Straightening further must cut in further; anything else leaves
// undefined corners inside the frame.
let mut small = Framing::new();
small.set_param(ANGLE, 2.0);
let mut large = Framing::new();
large.set_param(ANGLE, 10.0);
let a = small.max_inscribed_crop(6000, 4000);
let b = large.max_inscribed_crop(6000, 4000);
assert!(a.width > b.width, "{} should exceed {}", a.width, b.width);
assert!(a.width < 1.0, "a rotated frame cannot keep its full width");
}
#[test]
fn the_inscribed_crop_is_centred_and_inside_the_frame() {
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -7.5] {
let mut f = Framing::new();
f.set_param(ANGLE, angle);
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
let c = f.max_inscribed_crop(w, h);
assert!(
c.width > 0.0 && c.height > 0.0,
"{angle}° on {w}x{h}: {c:?} is degenerate"
);
assert!(
c.x + c.width <= 1.0 + 1e-4 && c.y + c.height <= 1.0 + 1e-4,
"{angle}° on {w}x{h}: {c:?} extends past the frame"
);
assert!(
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4,
"{angle}° on {w}x{h}: {c:?} is not centred"
);
}
}
}
#[test]
fn the_inscribed_crop_contains_no_undefined_area() {
// The property the derivation exists for, checked directly: every
// corner of the inscribed rect, mapped through the same transform the
// shader applies, must land inside the source.
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -12.0] {
let mut f = Framing::new();
f.set_param(ANGLE, angle);
let (w, h) = (6000.0f32, 4000.0f32);
let c = f.max_inscribed_crop(6000, 4000);
let rad = angle * PI / 180.0;
let (sn, cs) = (rad.sin(), rad.cos());
let aspect = w / h;
for (fx, fy) in [
(c.x, c.y),
(c.x + c.width, c.y),
(c.x, c.y + c.height),
(c.x + c.width, c.y + c.height),
] {
let (px, py) = ((fx - 0.5) * aspect, fy - 0.5);
let (rx, ry) = (px * cs - py * sn, px * sn + py * cs);
let (ux, uy) = (rx / aspect + 0.5, ry + 0.5);
assert!(
(-1e-3..=1.0 + 1e-3).contains(&ux) && (-1e-3..=1.0 + 1e-3).contains(&uy),
"{angle}°: corner ({fx}, {fy}) maps to ({ux}, {uy}), outside the source"
);
}
}
}
}
+129 -31
View File
@@ -8,7 +8,8 @@
//! so reordering the pipeline needs no code change.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
use crate::operation::{compose, ComposedShader, Operation};
use crate::framing::{CropRect, Framing};
use crate::operation::{compose_with_framing, ComposedShader, Operation};
use crate::ops;
/// TRACES: FR-DEV-3a
@@ -52,9 +53,16 @@ impl ParamCapability {
}
}
/// An ordered pipeline of operations.
/// 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,
}
impl EditGraph {
@@ -70,17 +78,49 @@ impl EditGraph {
ops: vec![
Box::new(ops::WhiteBalance::new()),
Box::new(ops::Exposure::new()),
Box::new(ops::Contrast::new()),
Box::new(ops::HighlightsShadows::new()),
Box::new(ops::BlacksWhites::new()),
Box::new(ops::Brilliance::new()),
Box::new(ops::Vibrance::new()),
Box::new(ops::Saturation::new()),
// The mixer comes last: it is the finishing control, and it
// should act on the tones the user has already settled.
Box::new(ops::ColourMixer::new()),
],
framing: Framing::new(),
}
}
/// Descriptors for every operation, in order. Drives panel generation
/// (FR-DEV-3a).
/// 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()
}
@@ -100,28 +140,47 @@ impl EditGraph {
/// step, and so reopening an edited image shows where the sliders
/// actually are.
pub fn capabilities(&self) -> Vec<OpCapability> {
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),
})
.collect(),
}
})
.collect()
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),
})
.collect(),
}
});
// 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),
})
.collect(),
};
ops.chain(std::iter::once(framing)).collect()
}
/// Set a parameter, clamping to the descriptor's declared range.
@@ -130,6 +189,15 @@ impl EditGraph {
/// has to defend against an out-of-range value, and a corrupt sidecar
/// cannot reach a shader.
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.
@@ -148,29 +216,51 @@ impl EditGraph {
/// 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 to its default.
/// 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();
}
/// Whether any operation currently changes the image.
/// 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);
}
/// Whether any operation, or the framing, currently changes the image.
pub fn is_neutral(&self) -> bool {
!self.ops.iter().any(|o| o.is_active())
!self.ops.iter().any(|o| o.is_active()) && !self.framing.is_active()
}
/// Generate the fused shader for the current state.
pub fn compose(&self) -> ComposedShader {
compose(&self.ops)
compose_with_framing(&self.ops, &self.framing)
}
}
@@ -406,8 +496,16 @@ mod tests {
})
.collect();
assert_eq!(rendered.len(), 10, "seven operations, ten parameters");
// 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]
+102 -6
View File
@@ -32,6 +32,7 @@
//! data neither would be physically meaningful (ARCH §5.2).
pub mod descriptor;
pub mod framing;
pub mod graph;
pub mod operation;
pub mod ops;
@@ -39,8 +40,11 @@ pub mod ops;
pub use descriptor::{
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
};
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
pub use operation::{compose, Affects, ComposedShader, Helper, Operation, Uniform};
pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
};
#[cfg(test)]
mod tests {
@@ -67,10 +71,12 @@ mod tests {
let g = fully_active();
assert!(!g.is_neutral());
let shader = g.compose();
// Counted against the chain rather than a literal, so adding an
// operation does not require editing this test.
assert_eq!(
shader.source.matches("---- ").count(),
7,
"all seven operations should appear"
g.descriptors().len(),
"every operation in the chain should appear"
);
}
@@ -148,16 +154,106 @@ mod tests {
#[test]
fn generated_uniform_names_are_valid_wgsl_identifiers() {
let shader = fully_active().compose();
for line in shader.source.lines() {
// Only the `struct Params` block. Scanning the whole source picks up
// helper *signatures* such as `fn contrast_curve(x: f32, ...)`, whose
// parameters are not uniform declarations at all.
let body = shader
.source
.split_once("struct Params {")
.expect("a uniform struct is always generated")
.1
.split_once('}')
.expect("the struct is closed")
.0;
let mut checked = 0;
for line in body.lines() {
let trimmed = line.trim();
let Some((name, _)) = trimmed.split_once(": f32,") else {
if trimmed.starts_with("//") {
continue;
}
let Some((name, _)) = trimmed.split_once(':') else {
continue;
};
let name = name.trim();
assert!(
name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit()),
"{name} is not a valid WGSL identifier"
);
checked += 1;
}
assert!(checked > 0, "the struct should declare fields");
}
#[test]
fn no_fragment_declares_a_wgsl_reserved_keyword() {
// Caught the hard way: `let target = ...` in the contrast fragment
// failed to compile with "name `target` is a reserved keyword", and
// the error pointed at generated source rather than at the operation
// that wrote it. Checking here names the culprit directly.
//
// Not the full reserved list — the ones a colour operation would
// plausibly reach for.
const RESERVED: &[&str] = &[
"target",
"sample",
"filter",
"texture",
"buffer",
"binding",
"const",
"enum",
"mat",
"vec",
"ptr",
"ref",
"shared",
"static",
"typedef",
"union",
"unless",
"handle",
"layout",
"packed",
"premerge",
"regardless",
"typedef",
"active",
"do",
"enum",
"input",
"output",
"private",
"resource",
"restrict",
"self",
"std",
"where",
];
let g = EditGraph::default_chain();
for cap in g.capabilities() {
// Activate the whole operation so its fragment is emitted.
let mut probe = EditGraph::default_chain();
for p in &cap.params {
if let ParamKind::Scalar { max, .. } = p.kind {
probe.set_param(cap.id, p.id, max * 0.5);
}
}
let source = probe.compose().source;
for keyword in RESERVED {
let declaration = format!("let {keyword} ");
let var_declaration = format!("var {keyword} ");
assert!(
!source.contains(&declaration) && !source.contains(&var_declaration),
"{} declares `{keyword}`, which is a WGSL reserved keyword",
cap.id
);
}
}
}
+166 -12
View File
@@ -23,6 +23,7 @@
use std::fmt::Write as _;
use crate::descriptor::{OpDescriptor, ParamId};
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
/// What an operation's parameters affect, for cache invalidation scoping.
///
@@ -131,7 +132,19 @@ const BASE_UNIFORM_FIELDS: usize = 16;
///
/// Inactive operations are skipped entirely — they contribute no code, no
/// uniforms, and nothing to the structure hash.
///
/// Equivalent to [`compose_with_framing`] with neutral framing.
pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
compose_with_framing(ops, &Framing::new())
}
/// Compose operations and framing into a single compute shader.
///
/// Framing generates the shader's **prologue** — the map from an output pixel
/// back to a source position — where [`compose`] would emit a fixed identity
/// scale. The fused-dispatch property is unaffected: a cropped, straightened
/// edit with three adjustments is still one dispatch, one read, one write.
pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> ComposedShader {
let active: Vec<&dyn Operation> = ops
.iter()
.map(|o| o.as_ref())
@@ -157,6 +170,18 @@ pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
);
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
// Framing's block follows the base one at a fixed offset, for the same
// reason: the prologue is emitted whether or not any operation is active,
// so these slots cannot be positioned by the op loop below.
uniform_fields.push_str(
" // Framing: the crop rect (origin, extent) and the straightening\n\
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
\x20 // value that is constant across the dispatch.\n\
\x20 crop_rect: vec4<f32>,\n\
\x20 framing_angle: vec4<f32>,\n",
);
uniform_values.extend_from_slice(&framing.uniforms());
for op in &active {
let id = op.descriptor().id.0;
let prefix = sanitise(id);
@@ -206,6 +231,19 @@ pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
}
// The coordinate stage: output pixel -> source position -> colour. Emitted
// ahead of the operation fragments, which receive the sampled `c`.
let prologue = format!(
"{}\n{}",
framing.wgsl_prologue(),
sample_source(framing.needs_interpolation())
);
let sampler_helper = if framing.needs_interpolation() {
BILINEAR_HELPER
} else {
""
};
let source = format!(
"// GENERATED — do not edit.
//
@@ -221,7 +259,7 @@ struct Params {{
@group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
{helper_src}// Linear sRGB to the display transfer function.
{sampler_helper}{helper_src}// Linear sRGB to the display transfer function.
//
// The one place quantisation happens: everything above runs in linear f16,
// and this is the final encode (ARCH §5.2).
@@ -238,14 +276,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
return;
}}
// Source is the demosaiced image: linear, scene-referred, camera space.
let src_dims = textureDimensions(source);
let coord = vec2<i32>(
i32(gid.x * src_dims.x / dims.x),
i32(gid.y * src_dims.y / dims.y),
);
var c = textureLoad(source, coord, 0).rgb;
{prologue}
// As-shot white balance. Applied unconditionally, before any operation,
// because it is part of *interpreting* the sensor rather than an edit: a
// Bayer sensor's green photosites collect far more signal than its red
@@ -271,7 +302,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
active.len()
);
let structure_hash = hash_structure(&active);
// Framing enters the hash by structure only — which branches its prologue
// generated, never how far a slider moved. Dragging the crop handles must
// reuse the compiled pipeline and re-upload uniforms.
let structure_hash = mix(hash_structure(&active), framing.structure_key());
ComposedShader {
source,
@@ -280,6 +314,83 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
}
}
/// The WGSL turning the framed source position `p` into the colour `c`.
///
/// Split out because it is the join between the coordinate stage and the
/// colour stage, and because the choice it makes — an exact integer load, or
/// a filtered sample — is the one thing the free-angle case changes.
fn sample_source(interpolate: bool) -> &'static str {
if interpolate {
" // Back to texture coordinates.
let uv_src = p / aspect + vec2<f32>(0.5);
// Outside the source there is no pixel. A straightened frame exposes its
// corners; render them black rather than clamping, which would smear an
// edge pixel across them.
if (any(uv_src < vec2<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
return;
}
// A free angle puts output pixels between source pixels. Nearest-neighbour
// here is what makes a straightened horizon stair-step, so interpolate.
var c = sample_bilinear(uv_src, src_dims);
"
} else {
" // Back to texture coordinates.
let uv_src = p / aspect + vec2<f32>(0.5);
// Outside the source there is no pixel — possible once the frame has been
// transformed at all. Render it black rather than clamping, which would
// smear an edge pixel across the gap.
if (any(uv_src < vec2<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
return;
}
// Every output pixel lands on a source pixel, so load it directly: exact,
// and with no interpolation to soften detail.
let coord = min(vec2<i32>(uv_src * vec2<f32>(src_dims)), vec2<i32>(src_dims) - vec2<i32>(1));
var c = textureLoad(source, coord, 0).rgb;
"
}
}
/// Bilinear sampling against an unfiltered `texture_2d`.
///
/// Hand-rolled rather than done with a sampler: the source is bound as a plain
/// texture, and adding a sampler for the straightening case alone would change
/// a bind group layout that every pass shares.
const BILINEAR_HELPER: &str = "fn sample_bilinear(uv: vec2<f32>, dims: vec2<u32>) -> vec3<f32> {
let last = vec2<i32>(dims) - vec2<i32>(1);
// Half-texel offset: sample positions are texel *centres*. Without it the
// image shifts by half a pixel and every rotation comes out slightly soft.
let q = uv * vec2<f32>(dims) - vec2<f32>(0.5);
let base = floor(q);
let f = q - base;
let i0 = clamp(vec2<i32>(base), vec2<i32>(0), last);
let i1 = min(i0 + vec2<i32>(1), last);
let c00 = textureLoad(source, vec2<i32>(i0.x, i0.y), 0).rgb;
let c10 = textureLoad(source, vec2<i32>(i1.x, i0.y), 0).rgb;
let c01 = textureLoad(source, vec2<i32>(i0.x, i1.y), 0).rgb;
let c11 = textureLoad(source, vec2<i32>(i1.x, i1.y), 0).rgb;
return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y);
}
";
/// Fold a value into a hash. FNV-1a's mixing step, over eight bytes.
fn mix(mut h: u64, value: u64) -> u64 {
for byte in value.to_le_bytes() {
h ^= u64::from(byte);
h = h.wrapping_mul(0x100_0000_01b3);
}
h
}
/// Hash the op-set and order — the structure, not the values.
///
/// Two edits with the same operations at different slider positions produce
@@ -305,13 +416,29 @@ fn hash_structure(active: &[&dyn Operation]) -> u64 {
///
/// Whole-word matching matters: an operation with uniforms `amount` and
/// `amount_hi` must not have the first rewrite corrupt the second.
fn rewrite_uniform(src: &str, name: &str, replacement: &str) -> String {
///
/// Shared with [`crate::warp`], which prefixes its uniforms by the same rule
/// and must not diverge from it.
/// Comments are skipped. A fragment explaining what `factor` does should not
/// have its prose rewritten to `u.saturation_factor` — the generated source
/// is meant to be read when a shader fails to compile, and mangled comments
/// make that harder rather than easier.
pub(crate) fn rewrite_uniform(src: &str, name: &str, replacement: &str) -> String {
let mut out = String::with_capacity(src.len());
let bytes = src.as_bytes();
let mut i = 0;
// Tracks whether the cursor sits inside a `//` comment. WGSL fragments
// use line comments only, so this needs no block-comment handling.
let mut in_comment = false;
while i < src.len() {
if src[i..].starts_with(name) {
if bytes[i] == b'\n' {
in_comment = false;
} else if !in_comment && src[i..].starts_with("//") {
in_comment = true;
}
if !in_comment && src[i..].starts_with(name) {
let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
let after = i + name.len();
let after_ok = after >= src.len() || !is_ident_byte(bytes[after]);
@@ -511,6 +638,33 @@ mod tests {
assert_eq!(got, "x = u.p_amount + amount_hi;");
}
#[test]
fn rewriting_leaves_comments_alone() {
// Found in generated source: a comment reading "A factor of 0 is
// monochrome" came out as "A u.saturation_factor of 0 is monochrome".
// The generated source is what gets read when a shader fails to
// compile, so mangling it works against the one time it matters.
let got = rewrite_uniform(
"// A factor of 0 is monochrome\nc = c * factor;",
"factor",
"u.op_factor",
);
assert_eq!(got, "// A factor of 0 is monochrome\nc = c * u.op_factor;");
}
#[test]
fn rewriting_resumes_after_a_comment_ends() {
let got = rewrite_uniform(
"// factor here is prose\nlet x = factor;\n// factor again\n",
"factor",
"u.p",
);
assert_eq!(
got,
"// factor here is prose\nlet x = u.p;\n// factor again\n"
);
}
#[test]
fn rewriting_leaves_substrings_alone() {
let got = rewrite_uniform("total_amount = 1.0;", "amount", "u.a");
+611
View File
@@ -0,0 +1,611 @@
//! The colour mixer — twelve hue bands, each with hue, saturation and
//! luminance.
//!
//! The control photographers mean by "per-colour adjustment": pick a colour
//! range, then shift its hue, deepen or mute it, or lighten it, without
//! touching the rest of the image. Thirty-six parameters in one operation.
//!
//! # Why bands overlap
//!
//! Each band has a centre hue and influences colours near it with a weight
//! that falls smoothly to zero at its neighbours' centres. A hard assignment
//! — "this pixel is orange, that one is yellow" — puts a visible seam through
//! any gradient crossing a boundary, and skies and skin are exactly where
//! that shows. Overlapping weights mean adjacent bands blend, and a colour
//! halfway between two centres receives half of each.
//!
//! # Why the weights are normalised
//!
//! With overlap, a pixel's weights sum to more than one, so applying each
//! band's gain independently would compound them. The shader normalises, so
//! setting every band's saturation to +100 gives the same result as setting
//! the global saturation to +100 rather than something far stronger.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("colour_mixer");
/// The twelve bands, in hue order starting at red.
///
/// Twelve rather than Lightroom's eight: the extra bands fall between the
/// primaries and secondaries, which is where skin (orange-to-red) and
/// foliage (yellow-to-green) actually sit, and where eight bands force a
/// compromise.
pub struct Band {
/// Stable id fragment, used to build parameter ids.
pub key: &'static str,
/// Centre hue in degrees.
pub hue: f32,
}
pub static BANDS: [Band; 12] = [
Band {
key: "red",
hue: 0.0,
},
Band {
key: "orange",
hue: 30.0,
},
Band {
key: "yellow",
hue: 60.0,
},
Band {
key: "chartreuse",
hue: 90.0,
},
Band {
key: "green",
hue: 120.0,
},
Band {
key: "spring",
hue: 150.0,
},
Band {
key: "cyan",
hue: 180.0,
},
Band {
key: "azure",
hue: 210.0,
},
Band {
key: "blue",
hue: 240.0,
},
Band {
key: "violet",
hue: 270.0,
},
Band {
key: "magenta",
hue: 300.0,
},
Band {
key: "rose",
hue: 330.0,
},
];
/// The three adjustments each band carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
Hue,
Saturation,
Luminance,
}
impl Channel {
pub const ALL: [Channel; 3] = [Channel::Hue, Channel::Saturation, Channel::Luminance];
pub const fn suffix(self) -> &'static str {
match self {
Channel::Hue => "hue",
Channel::Saturation => "sat",
Channel::Luminance => "lum",
}
}
}
// Parameter descriptors, one per band per channel. Written out rather than
// generated because `ParamDescriptor` must be `const` to live in a `static`,
// and a const loop cannot build a slice. The macro keeps it honest.
macro_rules! band_params {
($($key:literal),* $(,)?) => {
&[
$(
ParamDescriptor::amount(
concat!($key, "_hue"),
concat!("param.mixer.", $key, ".hue"),
),
ParamDescriptor::amount(
concat!($key, "_sat"),
concat!("param.mixer.", $key, ".sat"),
),
ParamDescriptor::amount(
concat!($key, "_lum"),
concat!("param.mixer.", $key, ".lum"),
),
)*
]
};
}
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.colour_mixer"),
params: band_params![
"red",
"orange",
"yellow",
"chartreuse",
"green",
"spring",
"cyan",
"azure",
"blue",
"violet",
"magenta",
"rose",
],
};
static MIXER_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
Helper {
name: "rgb_to_hcl",
source: "\
// Hue (degrees), chroma, and the max channel, in one pass.
//
// Not a full HSL conversion: the mixer needs hue to weight the bands and
// chroma to know how much colour there is to adjust, and computing lightness
// separately from Rec. 709 luminance gives a better-behaved result than
// HSL's (max+min)/2.
fn rgb_to_hcl(c: vec3<f32>) -> vec3<f32> {
let hi = max(c.r, max(c.g, c.b));
let lo = min(c.r, min(c.g, c.b));
let chroma = hi - lo;
var hue = 0.0;
if (chroma > 0.00001) {
if (hi == c.r) {
// fract handles the wrap from -60 to 300 without a branch.
hue = 60.0 * fract(((c.g - c.b) / chroma) / 6.0 + 1.0) * 6.0 / 6.0;
hue = 60.0 * (((c.g - c.b) / chroma) % 6.0);
if (hue < 0.0) { hue = hue + 360.0; }
} else if (hi == c.g) {
hue = 60.0 * (((c.b - c.r) / chroma) + 2.0);
} else {
hue = 60.0 * (((c.r - c.g) / chroma) + 4.0);
}
}
return vec3<f32>(hue, chroma, hi);
}",
},
Helper {
name: "band_weight",
source: "\
// How strongly a hue belongs to a band centred at `centre`.
//
// Cosine falloff over +/-60 degrees, so a band reaches zero exactly at its
// neighbours' centres and adjacent weights sum to one across the gap. A
// narrower window would leave hues between bands unreachable; a wider one
// would make every adjustment affect the whole wheel.
fn band_weight(hue: f32, centre: f32) -> f32 {
// Shortest angular distance, accounting for the wrap at 360.
var d = abs(hue - centre);
if (d > 180.0) { d = 360.0 - d; }
if (d >= 60.0) { return 0.0; }
// cos ramp: 1 at the centre, 0 at 60 degrees.
return 0.5 + 0.5 * cos(d * 3.14159265 / 60.0);
}",
},
Helper {
name: "hue_to_rgb_scale",
source: "\
// Rebuild a colour after shifting its hue, preserving chroma and level.
//
// Reconstructing from HSV rather than rotating in RGB: an RGB rotation
// matrix desaturates as it turns, which is visible as colours going pale
// mid-shift.
fn hue_to_rgb_scale(hue: f32, chroma: f32, hi: f32) -> vec3<f32> {
let h = fract(hue / 360.0) * 6.0;
let x = chroma * (1.0 - abs((h % 2.0) - 1.0));
var rgb = vec3<f32>(0.0);
if (h < 1.0) { rgb = vec3<f32>(chroma, x, 0.0); }
else if (h < 2.0) { rgb = vec3<f32>(x, chroma, 0.0); }
else if (h < 3.0) { rgb = vec3<f32>(0.0, chroma, x); }
else if (h < 4.0) { rgb = vec3<f32>(0.0, x, chroma); }
else if (h < 5.0) { rgb = vec3<f32>(x, 0.0, chroma); }
else { rgb = vec3<f32>(chroma, 0.0, x); }
return rgb + vec3<f32>(hi - chroma);
}",
},
];
/// Twelve hue bands, each with hue, saturation and luminance.
#[derive(Debug, Clone)]
pub struct ColourMixer {
/// `[band][channel]`, matching [`BANDS`] and [`Channel::ALL`].
values: [[f32; 3]; 12],
}
impl Default for ColourMixer {
fn default() -> Self {
Self {
values: [[0.0; 3]; 12],
}
}
}
impl ColourMixer {
pub fn new() -> Self {
Self::default()
}
/// The parameter id for one band and channel.
///
/// Ids are `"<band>_<channel>"`, matching the descriptors above.
fn index_of(id: ParamId) -> Option<(usize, usize)> {
let (band, channel) = id.0.rsplit_once('_')?;
let b = BANDS.iter().position(|x| x.key == band)?;
let c = Channel::ALL.iter().position(|x| x.suffix() == channel)?;
Some((b, c))
}
/// Whether any band has a non-zero setting.
fn any_set(&self) -> bool {
self.values.iter().flatten().any(|v| *v != 0.0)
}
}
impl Operation for ColourMixer {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match Self::index_of(id) {
Some((b, c)) => self.values[b][c] = value,
None => log::warn!("colour_mixer: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
Self::index_of(id).map_or(0.0, |(b, c)| self.values[b][c])
}
fn is_active(&self) -> bool {
self.any_set()
}
fn wgsl_body(&self) -> String {
// Only the bands the user actually touched contribute code. A single
// adjusted band therefore costs one weight evaluation rather than
// twelve — the composition property applied within an operation.
let mut lines = String::from(
"\
let hcl = rgb_to_hcl(c);
let hue = hcl.x;
let chroma = hcl.y;
let hi = hcl.z;
// Achromatic pixels have no hue to match, and adjusting them would tint
// neutrals — the most visible way a mixer can go wrong.
if (chroma > 0.0001) {
var w_total = 0.0;
var d_hue = 0.0;
var d_sat = 0.0;
var d_lum = 0.0;
",
);
for (b, band) in BANDS.iter().enumerate() {
let v = self.values[b];
if v.iter().all(|x| *x == 0.0) {
continue;
}
let key = band.key;
lines.push_str(&format!(
"\n // {key}\n {{\n let w = band_weight(hue, {:.1});\n w_total = w_total + w;\n",
band.hue
));
if v[0] != 0.0 {
lines.push_str(&format!(" d_hue = d_hue + w * {key}_hue;\n"));
}
if v[1] != 0.0 {
lines.push_str(&format!(" d_sat = d_sat + w * {key}_sat;\n"));
}
if v[2] != 0.0 {
lines.push_str(&format!(" d_lum = d_lum + w * {key}_lum;\n"));
}
lines.push_str(" }\n");
}
lines.push_str(
"
// Normalise by the total weight, so overlapping bands blend rather than
// compound. Without this, a hue sitting between two adjusted bands would
// receive roughly twice the intended adjustment.
if (w_total > 0.0001) {
d_hue = d_hue / w_total;
d_sat = d_sat / w_total;
d_lum = d_lum / w_total;
// Hue: up to 30 degrees at full travel. Enough to move foliage from
// yellow-green to green, not enough to turn it blue by accident.
let new_hue = hue + d_hue * 30.0;
// Saturation scales chroma; luminance scales the whole colour.
let new_chroma = clamp(chroma * (1.0 + d_sat), 0.0, hi);
c = hue_to_rgb_scale(new_hue, new_chroma, hi);
c = c * exp2(d_lum);
}
}
c = max(c, vec3<f32>(0.0));",
);
lines
}
fn uniforms(&self) -> Vec<Uniform> {
// Only the bands that contributed code declare uniforms, and in the
// same order the fragment references them.
let mut out = Vec::new();
for b in 0..BANDS.len() {
let v = self.values[b];
if v.iter().all(|x| *x == 0.0) {
continue;
}
// Names must match those the fragment emitted.
if v[0] != 0.0 {
out.push(Uniform {
name: HUE_NAMES[b],
value: v[0] / 100.0,
});
}
if v[1] != 0.0 {
out.push(Uniform {
name: SAT_NAMES[b],
value: v[1] / 100.0,
});
}
if v[2] != 0.0 {
out.push(Uniform {
name: LUM_NAMES[b],
// Up to half a stop per band.
value: v[2] / 100.0 * 0.5,
});
}
}
out
}
fn helpers(&self) -> &'static [Helper] {
MIXER_HELPERS
}
}
// Uniform names must be `&'static str`, and they are built from the band
// keys. Declared as tables rather than formatted at runtime, so the fragment
// and the uniform list cannot disagree.
static HUE_NAMES: [&str; 12] = [
"red_hue",
"orange_hue",
"yellow_hue",
"chartreuse_hue",
"green_hue",
"spring_hue",
"cyan_hue",
"azure_hue",
"blue_hue",
"violet_hue",
"magenta_hue",
"rose_hue",
];
static SAT_NAMES: [&str; 12] = [
"red_sat",
"orange_sat",
"yellow_sat",
"chartreuse_sat",
"green_sat",
"spring_sat",
"cyan_sat",
"azure_sat",
"blue_sat",
"violet_sat",
"magenta_sat",
"rose_sat",
];
static LUM_NAMES: [&str; 12] = [
"red_lum",
"orange_lum",
"yellow_lum",
"chartreuse_lum",
"green_lum",
"spring_lum",
"cyan_lum",
"azure_lum",
"blue_lum",
"violet_lum",
"magenta_lum",
"rose_lum",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn there_are_twelve_bands_with_thirty_six_parameters() {
assert_eq!(BANDS.len(), 12);
assert_eq!(DESCRIPTOR.params.len(), 36);
}
#[test]
fn bands_are_evenly_spaced_around_the_wheel() {
// Uneven spacing would leave some hues weakly covered, since the
// weight window is a fixed 60 degrees.
for (i, band) in BANDS.iter().enumerate() {
assert!(
(band.hue - i as f32 * 30.0).abs() < 1e-6,
"{} is at {}, expected {}",
band.key,
band.hue,
i as f32 * 30.0
);
}
}
#[test]
fn every_descriptor_id_resolves_to_a_band_and_channel() {
// The link between the descriptor list and the value array. A
// mismatch would make a slider silently adjust nothing.
for p in DESCRIPTOR.params {
assert!(
ColourMixer::index_of(p.id).is_some(),
"{} does not map to a band",
p.id
);
}
}
#[test]
fn every_band_and_channel_has_a_descriptor() {
// The reverse direction: a band with no descriptor is unreachable
// from the UI.
for band in BANDS.iter() {
for ch in Channel::ALL {
let id = format!("{}_{}", band.key, ch.suffix());
assert!(
DESCRIPTOR.params.iter().any(|p| p.id.0 == id),
"{id} has no descriptor"
);
}
}
}
#[test]
fn the_uniform_name_tables_match_the_band_keys() {
// Three parallel tables and a band list; if they drift, the fragment
// references a uniform that was never declared and the shader fails
// to compile.
for (i, band) in BANDS.iter().enumerate() {
assert_eq!(HUE_NAMES[i], format!("{}_hue", band.key));
assert_eq!(SAT_NAMES[i], format!("{}_sat", band.key));
assert_eq!(LUM_NAMES[i], format!("{}_lum", band.key));
}
}
#[test]
fn a_fresh_mixer_is_inactive() {
assert!(!ColourMixer::new().is_active());
}
#[test]
fn setting_any_band_activates_it() {
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_sat"), 40.0);
assert!(m.is_active());
assert_eq!(m.param(ParamId("blue_sat")), 40.0);
}
#[test]
fn only_adjusted_bands_reach_the_shader() {
// The composition property applied within an operation: adjusting
// one band must not cost twelve weight evaluations.
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_sat"), 40.0);
let body = m.wgsl_body();
assert!(body.contains("blue_sat"), "the adjusted band must appear");
assert!(!body.contains("red_sat"), "untouched bands must not");
assert_eq!(
body.matches("band_weight(").count(),
1,
"one adjusted band means one weight evaluation"
);
}
#[test]
fn only_adjusted_channels_within_a_band_reach_the_shader() {
let mut m = ColourMixer::new();
m.set_param(ParamId("green_lum"), -25.0);
let body = m.wgsl_body();
assert!(body.contains("green_lum"));
assert!(!body.contains("green_hue"));
assert!(!body.contains("green_sat"));
}
#[test]
fn the_fragment_and_uniforms_agree_on_names() {
// The failure this prevents is a compile error in generated code,
// which is far harder to read than a failed assertion here.
let mut m = ColourMixer::new();
m.set_param(ParamId("orange_hue"), 20.0);
m.set_param(ParamId("orange_sat"), -30.0);
m.set_param(ParamId("azure_lum"), 15.0);
let body = m.wgsl_body();
for u in m.uniforms() {
assert!(
body.contains(u.name),
"uniform {} is declared but never used",
u.name
);
}
// And nothing referenced without being declared.
let declared: Vec<&str> = m.uniforms().iter().map(|u| u.name).collect();
for band in BANDS.iter() {
for ch in Channel::ALL {
let name = format!("{}_{}", band.key, ch.suffix());
if body.contains(&name) {
assert!(
declared.contains(&name.as_str()),
"{name} is used but not declared"
);
}
}
}
}
#[test]
fn achromatic_pixels_are_excluded() {
// Adjusting a hue-less pixel would tint neutrals, which is the most
// visible way a mixer misbehaves.
let mut m = ColourMixer::new();
m.set_param(ParamId("red_sat"), 50.0);
assert!(m.wgsl_body().contains("chroma > 0.0001"));
}
#[test]
fn overlapping_weights_are_normalised() {
// Without normalising, a hue between two adjusted bands gets roughly
// double the intended adjustment.
let mut m = ColourMixer::new();
m.set_param(ParamId("red_sat"), 50.0);
m.set_param(ParamId("orange_sat"), 50.0);
let body = m.wgsl_body();
assert!(body.contains("d_sat / w_total"));
}
#[test]
fn unknown_parameters_are_ignored() {
let mut m = ColourMixer::new();
m.set_param(ParamId("puce_sat"), 50.0);
m.set_param(ParamId("malformed"), 50.0);
assert!(!m.is_active());
}
#[test]
fn luminance_travel_is_bounded_to_half_a_stop() {
let mut m = ColourMixer::new();
m.set_param(ParamId("blue_lum"), 100.0);
let v = m.uniforms()[0].value;
assert!((v - 0.5).abs() < 1e-6, "got {v}");
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Contrast — an S-curve about a fixed mid-point.
//!
//! Pushes tones away from middle grey (positive) or toward it (negative),
//! pivoting where the eye reads "neither light nor dark". In linear light
//! that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a
//! stop and a half above middle grey, and pivoting there would darken almost
//! every photograph.
//!
//! The curve is applied in a perceptual domain rather than directly to linear
//! values. Applied linearly, an S-curve crushes shadows far harder than it
//! lifts highlights, because linear light devotes most of its range to the
//! brightest stop.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("contrast");
pub const CONTRAST: ParamId = ParamId("contrast");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.contrast"),
params: &[ParamDescriptor::amount("contrast", "param.contrast")],
};
/// The helpers this operation needs, including its own S-curve.
static CONTRAST_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
helpers::APPLY_TONE_GAIN,
Helper {
name: "contrast_curve",
source: "\
// A symmetric S-curve on a 0..1 perceptual position.
//
// `amount` above zero steepens, below zero flattens. The smoothstep form is
// used for the steepening direction because it has zero gradient at both
// ends, so the curve cannot invert however hard it is pushed — the failure
// that makes naive gain-about-a-pivot unusable past moderate settings.
fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) {
// Blend toward a smoothstep, which is the S.
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
// Flattening: pull toward the mid-point. At amount = -1 every tone
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
return mix(clamped, 0.5, -amount);
}",
},
];
#[derive(Debug, Default, Clone)]
pub struct Contrast {
amount: f32,
}
impl Contrast {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for Contrast {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
CONTRAST => self.amount = value,
_ => log::warn!("contrast: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
CONTRAST => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"\
let luma = luminance(c);
if (luma > 0.0001) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
// curve operates on luma/(2*0.18) so that middle grey lands at the
// curve's own 0.5 pivot.
let pos = clamp(luma / 0.36, 0.0, 1.0);
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
c = max(c, vec3<f32>(0.0));"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "amount",
value: self.amount / 100.0,
}]
}
fn helpers(&self) -> &'static [Helper] {
CONTRAST_HELPERS
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operation::compose;
#[test]
fn neutral_does_nothing() {
let c = Contrast::new();
assert!(!c.is_active());
assert_eq!(c.uniforms()[0].value, 0.0);
}
#[test]
fn the_amount_is_normalised_to_unit_range() {
// The shader's curve expects -1..1; the descriptor speaks -100..100.
let mut c = Contrast::new();
c.set_param(CONTRAST, 100.0);
assert!((c.uniforms()[0].value - 1.0).abs() < 1e-6);
c.set_param(CONTRAST, -100.0);
assert!((c.uniforms()[0].value + 1.0).abs() < 1e-6);
}
#[test]
fn contrast_works_on_luminance_not_per_channel() {
// Curving each channel separately shifts hue; the ratio form is what
// keeps a blue sky blue as contrast rises.
let mut c = Contrast::new();
c.set_param(CONTRAST, 50.0);
let body = c.wgsl_body();
assert!(body.contains("luminance(c)"));
assert!(
body.contains("apply_tone_gain"),
"the colour must be scaled by a ratio, not curved per channel"
);
}
#[test]
fn the_pivot_is_middle_grey_not_half() {
// Pivoting at 0.5 in linear light would darken nearly every image:
// scene-referred 0.5 is well above what the eye calls mid-tone.
let c = Contrast::new();
assert!(
c.wgsl_body().contains("0.36"),
"the curve must pivot about middle grey (0.18, doubled to place \
it at the curve's own midpoint)"
);
}
#[test]
fn the_curve_cannot_invert() {
// A gain-about-a-pivot form produces a non-monotonic curve past
// moderate settings, which inverts tones. smoothstep cannot.
let helper = CONTRAST_HELPERS
.iter()
.find(|h| h.name == "contrast_curve")
.expect("declares its curve");
assert!(helper.source.contains("3.0 - 2.0 * clamped"));
}
#[test]
fn it_composes_with_the_other_tonal_operations() {
// Contrast, highlights/shadows and brilliance all want `luminance`;
// the composer must emit it once.
let ops: Vec<Box<dyn Operation>> = vec![
Box::new({
let mut o = Contrast::new();
o.set_param(CONTRAST, 40.0);
o
}),
Box::new({
let mut o = crate::ops::HighlightsShadows::new();
o.set_param(crate::ops::tone::HIGHLIGHTS, -30.0);
o
}),
];
let shader = compose(&ops);
assert_eq!(shader.source.matches("fn luminance(").count(), 1);
assert_eq!(shader.source.matches("fn apply_tone_gain(").count(), 1);
assert_eq!(shader.source.matches("fn contrast_curve(").count(), 1);
}
#[test]
fn a_division_by_luminance_is_guarded() {
// A black pixel has zero luminance; dividing by it would produce NaN
// and propagate through everything downstream.
assert!(
Contrast::new().wgsl_body().contains("luma > 0.0001"),
"the ratio must be guarded against black pixels"
);
}
}
+336
View File
@@ -0,0 +1,336 @@
//! Geometric distortion correction.
//!
//! Straightens the lines a lens bends: barrel distortion on wide angles,
//! pincushion on telephotos. A [`crate::warp::Warp`] rather than an
//! [`crate::operation::Operation`], because it changes *where* a pixel is read
//! from rather than what its value becomes.
//!
//! # The model
//!
//! Lensfun's `ptlens` model, matched deliberately so a lens profile from the
//! Lensfun database applies with no conversion:
//!
//! ```text
//! r_d = r_u · (a·r_u³ + b·r_u² + c·r_u + 1 − a − b − c)
//! ```
//!
//! The `1 − a − b − c` term is not decoration: it forces the polynomial to
//! equal 1 at `r_u = 1`, pinning the image corner in place. Without it every
//! coefficient change would also rescale the frame, so the distortion slider
//! would double as a zoom and no setting would leave the framing alone.
//!
//! `a` and `b` are the higher-order terms that describe a lens's real,
//! slightly wavy profile; `c` alone gives the simple barrel/pincushion shape.
//! The manual control drives `c` only — a single slider cannot meaningfully
//! set three correlated coefficients, and hand-correcting a lens with no
//! profile is a "make the horizon straight" task, which one term does well.
//! The full triple is reachable by loading a profile.
use crate::descriptor::{
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::operation::{Helper, Uniform};
use crate::warp::Warp;
pub const ID: OpId = OpId("distortion");
pub const AMOUNT: ParamId = ParamId("amount");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.distortion"),
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
// fisheye at one end and strong pincushion at the other; beyond it the
// inverse mapping stops being single-valued near the corners and the
// correction folds the image over itself.
params: &[ParamDescriptor::scalar(
"amount",
"param.distortion.amount",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
)],
};
/// The cubic coefficient at full slider travel.
const MAX_COEFF: f32 = 0.25;
#[derive(Debug, Default, Clone)]
pub struct Distortion {
amount: f32,
/// Profile coefficients, when a lens profile is loaded. `None` means the
/// manual slider drives `c` alone.
profile: Option<PtLens>,
}
/// The three `ptlens` coefficients, as Lensfun stores them.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PtLens {
pub a: f32,
pub b: f32,
pub c: f32,
}
impl Distortion {
pub fn new() -> Self {
Self::default()
}
/// Apply a lens profile's coefficients.
///
/// The manual slider then acts as a *trim* on top: photographers routinely
/// find a profile slightly over- or under-corrects on their copy of a
/// lens, and having to choose between "profile" and "manual" would make
/// that untunable.
pub fn set_profile(&mut self, profile: Option<PtLens>) {
self.profile = profile;
}
/// The effective coefficients: profile plus manual trim.
fn coefficients(&self) -> PtLens {
let trim = self.amount / 100.0 * MAX_COEFF;
match self.profile {
Some(p) => PtLens {
a: p.a,
b: p.b,
c: p.c + trim,
},
None => PtLens {
a: 0.0,
b: 0.0,
c: trim,
},
}
}
}
impl Warp for Distortion {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
AMOUNT => self.amount = value,
_ => log::warn!("distortion: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
AMOUNT => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
// A loaded profile corrects even with the slider at zero — that is
// the whole point of a profile.
let c = self.coefficients();
c.a != 0.0 || c.b != 0.0 || c.c != 0.0
}
fn wgsl_body(&self) -> String {
// Written against `p`, which is already normalised and centred.
"\
let r = length(p);
p = p * ptlens_scale(r, dist_a, dist_b, dist_c);"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
let c = self.coefficients();
vec![
Uniform {
name: "dist_a",
value: c.a,
},
Uniform {
name: "dist_b",
value: c.b,
},
Uniform {
name: "dist_c",
value: c.c,
},
]
}
fn helpers(&self) -> &'static [Helper] {
PTLENS
}
}
static PTLENS: &[Helper] = &[Helper {
name: "ptlens_scale",
source: "\
// The `ptlens` radial polynomial (Lensfun's model).
//
// Returns the factor mapping an undistorted radius to the distorted radius
// it should be sampled from. The trailing `1 - a - b - c` normalises the
// polynomial to 1 at r = 1, which pins the corner and stops a coefficient
// change from also rescaling the frame.
fn ptlens_scale(r: f32, a: f32, b: f32, c: f32) -> f32 {
let d = 1.0 - a - b - c;
return ((a * r + b) * r + c) * r + d;
}",
}];
#[cfg(test)]
mod tests {
use super::*;
/// The scale factor the shader would compute, mirrored on the CPU so the
/// maths is testable without a device (ARCH §6.5a).
fn scale(c: PtLens, r: f32) -> f32 {
let d = 1.0 - c.a - c.b - c.c;
((c.a * r + c.b) * r + c.c) * r + d
}
#[test]
fn neutral_does_nothing() {
let d = Distortion::new();
assert!(!d.is_active());
let c = d.coefficients();
assert_eq!((c.a, c.b, c.c), (0.0, 0.0, 0.0));
}
#[test]
fn a_neutral_polynomial_is_the_identity() {
// Every radius must map to itself when no correction is set,
// otherwise opening an image would resample it for nothing.
let c = Distortion::new().coefficients();
for r in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert!((scale(c, r) - 1.0).abs() < 1e-6, "r={r} was rescaled");
}
}
#[test]
fn the_corner_is_pinned_whatever_the_coefficients() {
// The property the `1 - a - b - c` term exists for: correction must
// not silently zoom the frame. If this fails, the distortion slider
// doubles as a crop and no setting leaves framing untouched.
for amount in [-100.0, -50.0, -1.0, 1.0, 50.0, 100.0] {
let mut d = Distortion::new();
d.set_param(AMOUNT, amount);
let s = scale(d.coefficients(), 1.0);
assert!(
(s - 1.0).abs() < 1e-5,
"amount {amount} moved the corner by {}",
s - 1.0
);
}
}
#[test]
fn the_centre_never_moves() {
// r = 0 is the optical axis; a radial model must leave it fixed, and
// `p * scale` does so for any finite scale.
let mut d = Distortion::new();
d.set_param(AMOUNT, 100.0);
assert!(scale(d.coefficients(), 0.0).is_finite());
}
#[test]
fn positive_amounts_correct_barrel_distortion() {
// Barrel distortion pushes detail outward, so correcting it must
// sample from further out at mid radii — an inverse map (see the
// `warp` module docs), which is why "correct barrel" magnifies.
let mut d = Distortion::new();
d.set_param(AMOUNT, 100.0);
let s = scale(d.coefficients(), 0.5);
assert!(s < 1.0, "mid-radius scale was {s}, expected < 1");
}
#[test]
fn negative_amounts_go_the_other_way() {
let mut pin = Distortion::new();
pin.set_param(AMOUNT, -100.0);
let mut bar = Distortion::new();
bar.set_param(AMOUNT, 100.0);
assert!(scale(pin.coefficients(), 0.5) > scale(bar.coefficients(), 0.5));
}
#[test]
fn the_mapping_stays_monotonic_across_the_whole_range() {
// If radius stops increasing with radius, the correction folds the
// image over itself and produces a mirrored ring. This is what bounds
// the slider at ±100, so it is worth asserting rather than trusting.
for amount in [-100.0, -50.0, 0.0, 50.0, 100.0] {
let mut d = Distortion::new();
d.set_param(AMOUNT, amount);
let c = d.coefficients();
let mut prev = 0.0;
for i in 1..=100 {
let r = i as f32 / 100.0;
let mapped = r * scale(c, r);
assert!(
mapped > prev,
"amount {amount}: mapping folded at r={r} ({mapped} <= {prev})"
);
prev = mapped;
}
}
}
#[test]
fn a_profile_corrects_with_the_slider_at_zero() {
// Loading a lens profile must do something on its own; requiring the
// user to also move a slider would make profiles pointless.
let mut d = Distortion::new();
assert!(!d.is_active());
d.set_profile(Some(PtLens {
a: 0.0168,
b: -0.0320,
c: -0.0287,
}));
assert!(d.is_active());
assert_eq!(d.param(AMOUNT), 0.0);
}
#[test]
fn the_slider_trims_a_loaded_profile_rather_than_replacing_it() {
// A profile that over-corrects on this copy of the lens must stay
// tunable, so the manual control adds to `c` and leaves a and b.
let profile = PtLens {
a: 0.01,
b: -0.02,
c: 0.03,
};
let mut d = Distortion::new();
d.set_profile(Some(profile));
d.set_param(AMOUNT, 100.0);
let c = d.coefficients();
assert_eq!(c.a, profile.a, "the profile's a must survive a trim");
assert_eq!(c.b, profile.b);
assert!((c.c - (profile.c + MAX_COEFF)).abs() < 1e-6);
}
#[test]
fn a_profile_can_be_cleared() {
let mut d = Distortion::new();
d.set_profile(Some(PtLens {
a: 0.01,
b: 0.0,
c: 0.0,
}));
assert!(d.is_active());
d.set_profile(None);
assert!(!d.is_active(), "clearing a profile must return to neutral");
}
#[test]
fn the_wgsl_body_reads_its_declared_uniforms() {
// The composer rewrites bare names; a body naming something it did
// not declare would compile to a reference to a nonexistent field.
let mut d = Distortion::new();
d.set_param(AMOUNT, 50.0);
let body = d.wgsl_body();
for u in d.uniforms() {
assert!(body.contains(u.name), "{} is declared but unused", u.name);
}
}
}
+4
View File
@@ -6,12 +6,16 @@
//! shader to edit, no UI change (FR-DEV-3c).
pub mod colour;
pub mod colour_mixer;
pub mod contrast;
pub mod exposure;
pub mod helpers;
pub mod tone;
pub mod white_balance;
pub use colour::{Brilliance, Saturation, Vibrance};
pub use colour_mixer::ColourMixer;
pub use contrast::Contrast;
pub use exposure::Exposure;
pub use tone::{BlacksWhites, HighlightsShadows};
pub use white_balance::WhiteBalance;
+314
View File
@@ -0,0 +1,314 @@
//! Coordinate-domain operations — the geometry half of the pipeline.
//!
//! # Why this is not `Operation`
//!
//! Every [`crate::operation::Operation`] is a function from colour to colour:
//! `wgsl_body` receives `c: vec3<f32>` and produces one. That shape cannot
//! express lens correction, and the reason is worth stating precisely because
//! it is what justifies a second trait rather than an extension of the first.
//!
//! Distortion does not change a pixel's value; it changes **which pixel you
//! read**. Chromatic aberration is worse still: lateral CA is a per-channel
//! radial magnification, so red, green and blue must be fetched from three
//! *different* coordinates. No function of an already-fetched `vec3<f32>` can
//! recover that — by the time a colour reaches an `Operation`, the three
//! channels have been sampled together and the information is gone.
//!
//! So a warp runs **before** the fetch, and composes into the generated
//! shader ahead of it (ARCH §5.2 places lens corrections in the geometry
//! half of the chain).
//!
//! # Inverse mapping
//!
//! A warp declares where an output pixel's colour **came from**, not where an
//! input pixel goes. This is not a stylistic choice:
//!
//! - A forward map is a *scatter* — each input pixel writes somewhere. In a
//! compute shader that needs atomics, leaves holes where the map expands,
//! and races where it contracts.
//! - An inverse map is a *gather* — each output pixel reads somewhere. One
//! dispatch, one write per pixel, no contention, and hole-free by
//! construction.
//!
//! So `undistort` is expressed as "given this output position, which source
//! position feeds it?". For a barrel-distorting lens that means the warp
//! *magnifies* the radius, which reads backwards until you remember the
//! direction is inverse.
//!
//! # Coordinate space
//!
//! Warps work in **normalised centred** coordinates: the image centre is
//! `(0, 0)`, and the radius is scaled so that `r == 1` at the corner. Both
//! properties matter.
//!
//! Centring is what makes the polynomial meaningful — lens distortion is
//! radially symmetric about the optical axis, so a formula written about any
//! other origin would need cross terms to say the same thing.
//!
//! Corner normalisation is what makes a coefficient **portable across
//! resolutions and aspect ratios**: the same value describes the lens whether
//! applied to a full-resolution export, a 512px thumbnail, or a cropped
//! frame. Normalising to the shorter edge instead — the other obvious choice
//! — would make a coefficient mean different things on a 3:2 and a 16:9 body
//! wearing the same lens, which defeats the point of a lens profile.
use std::fmt::Write as _;
use crate::descriptor::{OpDescriptor, ParamId};
use crate::operation::{Helper, Uniform};
/// A coordinate-domain operation, applied before the source is sampled.
///
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
/// graph holds `Box<dyn Warp>` in order, so the geometry chain is data.
pub trait Warp: Send + Sync {
/// Static description, driving UI generation exactly as for an operation.
fn descriptor(&self) -> &'static OpDescriptor;
/// Set a parameter. Values arrive already clamped to the descriptor.
fn set_param(&mut self, id: ParamId, value: f32);
/// Read a parameter back.
fn param(&self, id: ParamId) -> f32;
/// Whether this warp currently moves any pixel.
///
/// A warp at neutral is omitted from the shader entirely — and if *every*
/// warp is neutral the generated shader keeps its integer `textureLoad`
/// path rather than paying for a bilinear sample it does not need.
fn is_active(&self) -> bool;
/// The WGSL body of this warp's inverse coordinate transform.
///
/// Receives `p` (a `vec2<f32>`, normalised and centred per the module
/// docs) and must leave the **source** position in `p`.
///
/// A warp needing per-channel divergence writes `p_r` and `p_b` as well;
/// they enter the block equal to `p` and are carried out of it. A warp
/// that ignores them costs nothing — the composer drops the per-channel
/// path when no active warp declares [`Self::splits_channels`].
///
/// Uniforms are addressed by their bare declared names, as for an
/// operation; the composer rewrites them to their prefixed fields.
fn wgsl_body(&self) -> String;
/// Uniform values this warp's body reads.
fn uniforms(&self) -> Vec<Uniform>;
/// Whether this warp moves the channels independently.
///
/// True only for chromatic aberration. When no active warp declares it,
/// the composer emits a single sample instead of three — a 3× saving in
/// texture bandwidth for the common case of distortion alone, which at
/// 24 MP is the difference the tile budget is measured in.
fn splits_channels(&self) -> bool {
false
}
/// Any WGSL helper functions the body calls.
fn helpers(&self) -> &'static [Helper] {
&[]
}
}
/// The composed geometry stage: WGSL, uniforms, and what it needs from the
/// sampler.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ComposedWarp {
/// The WGSL block computing source coordinates, or empty when no warp is
/// active.
pub body: String,
/// Helper functions the body calls.
pub helpers: Vec<Helper>,
/// Uniform declarations, to be appended to the generated struct.
pub uniform_fields: String,
/// Uniform values, in declaration order.
pub uniforms: Vec<f32>,
/// Whether any active warp samples the channels separately.
pub splits_channels: bool,
}
impl ComposedWarp {
/// Whether any warp is active. When false the shader samples with an
/// integer `textureLoad` and no interpolation at all.
pub fn is_active(&self) -> bool {
!self.body.is_empty()
}
}
/// Compose the active warps into one coordinate transform.
///
/// Warps chain in order: each receives the position the previous one produced,
/// so correcting distortion and then CA composes as a single expression with
/// no intermediate buffer.
pub fn compose_warps(warps: &[Box<dyn Warp>]) -> ComposedWarp {
let active: Vec<&dyn Warp> = warps
.iter()
.map(|w| w.as_ref())
.filter(|w| w.is_active())
.collect();
if active.is_empty() {
return ComposedWarp::default();
}
let mut out = ComposedWarp {
splits_channels: active.iter().any(|w| w.splits_channels()),
..Default::default()
};
for warp in &active {
let id = warp.descriptor().id.0;
let prefix = sanitise(id);
let warp_uniforms = warp.uniforms();
if !warp_uniforms.is_empty() {
let _ = writeln!(out.uniform_fields, " // {id}");
}
for u in &warp_uniforms {
let _ = writeln!(out.uniform_fields, " {prefix}_{}: f32,", u.name);
out.uniforms.push(u.value);
}
for h in warp.helpers() {
if !out.helpers.iter().any(|e| e.name == h.name) {
out.helpers.push(*h);
}
}
let mut fragment = warp.wgsl_body();
for u in &warp_uniforms {
fragment = crate::operation::rewrite_uniform(
&fragment,
u.name,
&format!("u.{prefix}_{}", u.name),
);
}
let _ = writeln!(out.body, "\n // ---- warp: {id} ----");
let _ = writeln!(out.body, " {{");
for line in fragment.lines() {
let _ = writeln!(out.body, " {line}");
}
let _ = writeln!(out.body, " }}");
}
out
}
fn sanitise(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor};
static DESC_A: OpDescriptor = OpDescriptor {
id: OpId("warp_a"),
label: LocalizedKey("a"),
params: &[ParamDescriptor::amount("amount", "a.amount")],
};
static DESC_B: OpDescriptor = OpDescriptor {
id: OpId("warp_b"),
label: LocalizedKey("b"),
params: &[ParamDescriptor::amount("amount", "b.amount")],
};
struct Fake {
desc: &'static OpDescriptor,
amount: f32,
splits: bool,
}
impl Warp for Fake {
fn descriptor(&self) -> &'static OpDescriptor {
self.desc
}
fn set_param(&mut self, _id: ParamId, value: f32) {
self.amount = value;
}
fn param(&self, _id: ParamId) -> f32 {
self.amount
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"p = p * amount;".into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "amount",
value: self.amount,
}]
}
fn splits_channels(&self) -> bool {
self.splits
}
}
fn fake(desc: &'static OpDescriptor, amount: f32, splits: bool) -> Box<dyn Warp> {
Box::new(Fake {
desc,
amount,
splits,
})
}
#[test]
fn no_active_warp_composes_to_nothing() {
// The property that keeps the common case free: an image with no lens
// correction must not pay for a bilinear sample.
let composed = compose_warps(&[fake(&DESC_A, 0.0, false)]);
assert!(!composed.is_active());
assert!(composed.uniforms.is_empty());
assert!(!composed.splits_channels);
}
#[test]
fn an_active_warp_appears_once() {
let composed = compose_warps(&[fake(&DESC_A, 2.0, false)]);
assert!(composed.is_active());
assert!(composed.body.contains("---- warp: warp_a ----"));
assert!(composed.body.contains("u.warp_a_amount"));
}
#[test]
fn uniforms_are_prefixed_so_warps_cannot_collide() {
// Both fakes declare `amount`; without prefixing the generated struct
// would carry a duplicate field and fail to compile.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)]);
assert!(composed.uniform_fields.contains("warp_a_amount: f32"));
assert!(composed.uniform_fields.contains("warp_b_amount: f32"));
assert_eq!(composed.uniforms, vec![1.0, 2.0]);
}
#[test]
fn channel_splitting_is_requested_by_any_active_warp() {
// One CA warp among several must switch the whole stage to the
// three-sample path.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, true)]);
assert!(composed.splits_channels);
}
#[test]
fn an_inactive_splitting_warp_does_not_force_three_samples() {
// CA present but at neutral must cost nothing — otherwise every image
// with the panel visible pays triple bandwidth.
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 0.0, true)]);
assert!(composed.is_active());
assert!(!composed.splits_channels);
}
#[test]
fn warps_compose_in_order() {
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
let a = composed.body.find("warp_a").expect("a present");
let b = composed.body.find("warp_b").expect("b present");
assert!(a < b, "warps must chain in graph order");
}
}