Files
DarkRoom/core/dr-pipeline/src/ops/curve.rs
T
dtourolle 0682d05f95 Let the tone curve continue past 1.0, and test that nothing clips
The tone curve clamped its input to [0, 1] and its output back to 1.0
around a 2.2 gamma, mid-chain — master and per-channel both. So any
edit with a curve made every scene value above 1.0 the same number
before the view transform ever saw it: D19's fourth finding. Beyond its
last point the curve now continues along its last span, whose secant
is the tangent the spline already gives that point, so the join is
smooth and an identity curve stays the identity to any height. The only
clamp left is the floor at zero, where there is no light to curve.

FR-DEV-2's acceptance test is how the rule stays true.
`scene_referred_until_the_view` wraps every point operation, at
non-neutral settings, between a gain of sixteen and one of a
sixty-fourth, with an identity in the view transform's place, and
renders a ramp from 0.4 to 15.2 times sensor saturation through it. The
output must still increase with the input and still separate the top of
the ramp. Run against the previous commit's tone curve it fails with
[21, 29, 33, 33, 33, 33, 33, 33]; every other operation already passed.
It renders on a device because dr-pipeline has none, and the spec's
pointer to it says so.
2026-09-27 16:52:55 -04:00

1436 lines
55 KiB
Rust

//! TRACES: FR-DEV-3
//! The tone curve — four monotonic splines through five movable points each:
//! a master curve over tone, and one per colour channel.
//!
//! The control every other tonal adjustment is a preset of. Highlights,
//! shadows, blacks and whites each shape one region with a fixed weight; the
//! curve lets the photographer put the inflection exactly where the image
//! needs it. The per-channel curves are the same instrument pointed at colour:
//! a lifted blue black point is the faded shadow every film emulation is built
//! out of, and there is no way to ask for it with a saturation slider.
//!
//! # Why the points are ordinary scalars
//!
//! Each point is two [`ParamKind::Scalar`] parameters, x and y. The curve
//! widget is a *presentation* of those scalars (see
//! [`Operation::presentation`]), not a separate kind of value. Three things
//! follow, and all three are why it is built this way:
//!
//! - The parameter API stays `f32`-only, so nothing else in the pipeline,
//! the graph or the sidecar had to change to accommodate a curve.
//! - A UI that has not implemented the curve widget renders forty sliders and
//! remains completely functional.
//! - Undo, clamping and sidecar serialisation work already, because the
//! points are the same kind of thing as every other parameter.
//!
//! The cost is a fixed point count. Adding or removing points at will would
//! need a variable-length value type, which is a much larger change for a
//! control that rarely needs more than five.
//!
//! [`ParamKind::Scalar`]: crate::descriptor::ParamKind::Scalar
//!
//! # Why monotonic
//!
//! A plain cubic spline through user-placed points overshoots: drag one point
//! and the curve can dip *below* its neighbour, which inverts tones locally
//! and shows up as a dark halo in a smooth gradient. The Fritsch-Carlson
//! filter constrains the tangents so the interpolant is monotone wherever the
//! data is, which is exactly the guarantee a tone curve needs. It is enforced
//! per curve, because "the master's points are in order" says nothing whatever
//! about the blue one's.
//!
//! # Four curves, and the order they run in
//!
//! The master curve runs **first**, and the per-channel curves run on the
//! colour it produced. The two orders are not cosmetically different — an
//! S-curve followed by a lifted blue black point is a visibly different image
//! from the blue lift followed by the S-curve — so the choice has to be made
//! here and stated, rather than left to whichever loop was written first.
//!
//! It is made this way for two reasons.
//!
//! **A control point's x coordinate should mean the tone the photographer can
//! see.** The channel curves are the finishing grade — warm the shadows, cool
//! the highlights — and the tones being graded are the ones on screen, which
//! are the master curve's output. Running the channels first would anchor them
//! to the tones the master is *about to move*: place a warm shadow, then reach
//! for contrast, and the warmth migrates up into the midtones as the master
//! lifts the region the channel curve was pinned to. In this order the master
//! reshapes what reaches the grade, and the grade stays where it was put on
//! the axis the widget draws.
//!
//! **Tone before colour is the order the rest of the chain already runs in.**
//! The master curve is hue-preserving by construction: it curves *luminance*
//! and reapplies the result as a ratio, exactly as contrast does, so it is a
//! tonal operation and nothing else. The per-channel curves deliberately break
//! that ratio — they are the only part of this operation that can change a
//! hue. Putting the chromatic half last keeps this node in step with the chain
//! around it, where the colour mixer is the finishing control and acts on the
//! tones the tonal operations have already settled (`ops/README.md`).
//!
//! # What four curves cost when three of them are untouched
//!
//! Nothing. Each curve is emitted into the fragment and into the uniform block
//! only when it differs from the identity, so the overwhelmingly common edit —
//! an S-curve on the master and no per-channel work at all — generates exactly
//! the shader it generated when this file held one curve, down to the uniform
//! names. An operation whose four curves are all identity is inactive and
//! contributes no code, no uniform and no branch, which is the property the
//! whole composition scheme rests on (ARCH §5.6).
use std::fmt::Write as _;
use std::sync::{Arc, LazyLock};
use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
Scale, Unit, WidgetDemand, WidgetKind,
};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("tone_curve");
/// How many movable points a curve has.
///
/// Five: the two endpoints, a mid-tone, and one either side. Enough for the
/// S-curves and shoulder rolls that make up nearly every tonal edit, few
/// enough that the shader can evaluate them without a loop over storage.
pub const POINTS: usize = 5;
/// TRACES: FR-DEV-3
/// Which of the four curves a point belongs to.
///
/// `Master` is the curve that existed before the other three, and it keeps
/// that position in every list here: it is the one a photographer reaches for
/// first, it is the one that runs first, and — see [`Channel::prefix`] — it is
/// the one whose parameter ids may not change.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
/// Tone, applied to all three components through a luminance ratio.
Master,
Red,
Green,
Blue,
}
/// How many curves the operation carries.
pub const CHANNELS: usize = Channel::ALL.len();
impl Channel {
/// Every curve, in the order they are applied and presented.
///
/// Master first because it runs first — see the module documentation for
/// why that is the composition order — and because a list showing the
/// grade before the tone would be describing a different operation.
pub const ALL: [Channel; 4] = [Channel::Master, Channel::Red, Channel::Green, Channel::Blue];
/// Position in [`Self::ALL`], and so in every array keyed by channel.
pub const fn index(self) -> usize {
match self {
Channel::Master => 0,
Channel::Red => 1,
Channel::Green => 2,
Channel::Blue => 3,
}
}
/// TRACES: FR-CAT-8
/// What this channel's parameter ids are prefixed with.
///
/// **The master's prefix is empty, and that is a compatibility guarantee
/// rather than a saving of two characters.** A sidecar is the
/// authoritative store of an edit (ARCH §6.12) and it keys parameters by
/// `op.param` text, so `tone_curve.p2_y`, written by a build that had only
/// one curve, has to keep meaning the master's third point for as long as
/// those files exist. Every id that existed before the channels did is
/// therefore still spelled exactly as it was, and the new ones are spelled
/// differently — rather than the old ones being renamed into a scheme that
/// reads more evenly and loses every edit in the field.
///
/// It is also why the channel is a *prefix*. A suffix would collide with
/// the axis — `p2_y_r` is one underscore from a point called `y_r` — and
/// the parse in [`ToneCurve::index_of`] would have to read the end of the
/// string to know how to read the beginning of it.
pub const fn prefix(self) -> &'static str {
match self {
Channel::Master => "",
Channel::Red => "r_",
Channel::Green => "g_",
Channel::Blue => "b_",
}
}
/// The WGSL vector component this curve is applied to, or `None` for the
/// master, which acts on all three through luminance.
const fn component(self) -> Option<&'static str> {
match self {
Channel::Master => None,
Channel::Red => Some("r"),
Channel::Green => Some("g"),
Channel::Blue => Some("b"),
}
}
/// The localisation key naming this curve.
///
/// A key, not a word: resolving one needs a localiser and `core/` must not
/// depend on one (NFR-A11Y-1). It reaches the interface as a
/// [`Facet::subject`] — see [`facet_of`] — which is how a panel comes to
/// draw a channel selector without this file knowing that selectors exist.
pub const fn subject(self) -> LocalizedKey {
LocalizedKey(match self {
Channel::Master => "channel.rgb",
Channel::Red => "channel.red",
Channel::Green => "channel.green",
Channel::Blue => "channel.blue",
})
}
/// Where this channel sits on the hue wheel, in degrees.
///
/// Data about the operation, not a decision about appearance: the red
/// curve genuinely acts on the primary at 0°. Whether a frontend draws a
/// swatch from it, and in what shade, is the frontend's to decide
/// (ARCH §4.3a) — which is why this is a number and not a colour. `None`
/// for the master, whose subject is tone rather than a colour.
pub const fn hue(self) -> Option<f32> {
match self {
Channel::Master => None,
Channel::Red => Some(0.0),
Channel::Green => Some(120.0),
Channel::Blue => Some(240.0),
}
}
/// This channel's ten point parameters, x and y interleaved.
pub fn params(self) -> &'static [ParamId] {
let base = self.index() * POINTS * 2;
&CURVE_PARAMS[base..base + POINTS * 2]
}
}
/// Which coordinate of a point, for [`coordinate`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
X,
Y,
}
/// TRACES: FR-DEV-3
/// The parameter id for one coordinate of one channel's curve.
///
/// How a caller outside this module addresses a point. The alternative — forty
/// public constants — would write the layout of the parameter list into every
/// call site, where it would then have to agree forever; a caller that wants
/// point 2 of the blue curve says so.
///
/// Panics if `point` is out of range, which is a programming error rather than
/// anything a file or a user can cause: ids arriving from a sidecar go through
/// [`ToneCurve::index_of`], which returns an option.
pub fn coordinate(channel: Channel, point: usize, axis: Axis) -> ParamId {
assert!(point < POINTS, "the curve has {POINTS} points");
let axis = match axis {
Axis::X => 0,
Axis::Y => 1,
};
CURVE_PARAMS[channel.index() * POINTS * 2 + point * 2 + axis]
}
// The master curve's parameter ids, named because they were named before the
// channels existed: the tests, the develop example and the history's
// coalescing test all address points through them, and a sidecar in the field
// spells them exactly like this.
pub const P0_X: ParamId = ParamId("p0_x");
pub const P0_Y: ParamId = ParamId("p0_y");
pub const P1_X: ParamId = ParamId("p1_x");
pub const P1_Y: ParamId = ParamId("p1_y");
pub const P2_X: ParamId = ParamId("p2_x");
pub const P2_Y: ParamId = ParamId("p2_y");
pub const P3_X: ParamId = ParamId("p3_x");
pub const P3_Y: ParamId = ParamId("p3_y");
pub const P4_X: ParamId = ParamId("p4_x");
pub const P4_Y: ParamId = ParamId("p4_y");
/// The ten point ids of one channel, in point order, x before y.
///
/// A macro because `concat!` needs literals — the same reason the colour
/// mixer's band parameters are macro-generated — and because writing forty ids
/// out by hand is forty chances to transpose two characters in a way that
/// compiles and silently drives the wrong point.
macro_rules! channel_ids {
($($prefix:literal),* $(,)?) => {
[$(
ParamId(concat!($prefix, "p0_x")), ParamId(concat!($prefix, "p0_y")),
ParamId(concat!($prefix, "p1_x")), ParamId(concat!($prefix, "p1_y")),
ParamId(concat!($prefix, "p2_x")), ParamId(concat!($prefix, "p2_y")),
ParamId(concat!($prefix, "p3_x")), ParamId(concat!($prefix, "p3_y")),
ParamId(concat!($prefix, "p4_x")), ParamId(concat!($prefix, "p4_y")),
)*]
};
}
/// The parameters the curve widget owns: every channel, in point order.
///
/// The widget claims all forty, so a frontend that draws the curve draws all
/// four of them and no point appears a second time as a stray slider beneath
/// it. The prefixes are the ones [`Channel::prefix`] declares, spelled out
/// again here because `concat!` cannot call a function; `the_ids_match_the_
/// channel_prefixes` is what stops the two drifting.
static CURVE_PARAMS: [ParamId; CHANNELS * POINTS * 2] = channel_ids!["", "r_", "g_", "b_"];
/// The uniform names each channel's fragment reads, x and y interleaved.
///
/// Parallel to [`CURVE_PARAMS`] and deliberately its own table: uniform names
/// are the shader's business and parameter ids are the sidecar's, and tying
/// the two together would make a rename in one file change the meaning of the
/// other. The master's are unprefixed for the same reason its parameters are —
/// a master-only edit generates the shader it always generated, so nothing
/// that keyed on that source has to notice the channels arriving.
static UNIFORM_NAMES: [[&str; POINTS * 2]; CHANNELS] = [
["x0", "y0", "x1", "y1", "x2", "y2", "x3", "y3", "x4", "y4"],
[
"r_x0", "r_y0", "r_x1", "r_y1", "r_x2", "r_y2", "r_x3", "r_y3", "r_x4", "r_y4",
],
[
"g_x0", "g_y0", "g_x1", "g_y1", "g_x2", "g_y2", "g_x3", "g_y3", "g_x4", "g_y4",
],
[
"b_x0", "b_y0", "b_x1", "b_y1", "b_x2", "b_y2", "b_x3", "b_y3", "b_x4", "b_y4",
],
];
/// A coordinate parameter: 0…1 with enough precision to place a point
/// exactly, and a default putting the curve on the identity diagonal.
const fn coord(id: &'static str, label: &'static str, default: f32) -> ParamDescriptor {
ParamDescriptor::scalar(
id,
label,
0.0,
1.0,
default,
Unit::None,
Scale::Linear,
// A 4-decimal step is well under a pixel of widget travel, so the
// control never feels quantised.
4,
)
}
/// TRACES: FR-DEV-3a
/// Where a coordinate sits in the operation's grid.
///
/// **This is how the channel dimension reaches the interface without the
/// interface learning what a channel is.** The forty parameters are one
/// control — a point coordinate — applied to four subjects, which is exactly
/// what a [`Facet`] describes and the same shape the colour mixer uses for its
/// twelve hue bands. A panel that groups a widget's parameters by their
/// subject gets a four-way selector over the curves for free, names each entry
/// from the key the channel published, and never contains the word "red".
///
/// A frontend is free to ignore all of it and render forty sliders; nothing
/// becomes unreachable, it merely reads as forty anonymous coordinates.
const fn facet_of(aspect: &'static str, channel: Channel) -> Facet {
Facet {
// What this parameter adjusts: one coordinate of one point. Four
// parameters share it — the same point on each of the four curves.
aspect: LocalizedKey(aspect),
// What it adjusts it on.
subject: channel.subject(),
subject_hue: channel.hue(),
}
}
/// One channel's ten descriptors, defaulted onto the identity diagonal.
///
/// Written out per point rather than looped because `concat!` needs literals:
/// the parameter ids are built from the channel's prefix, and a runtime loop
/// has no way to spell `r_p0_x`.
macro_rules! channel_params {
($(($prefix:literal, $channel:expr)),* $(,)?) => {
vec![$(
coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0)
.faceted(facet_of("param.curve.p0_x", $channel)),
coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0)
.faceted(facet_of("param.curve.p0_y", $channel)),
coord(concat!($prefix, "p1_x"), "param.curve.p1_x", 0.25)
.faceted(facet_of("param.curve.p1_x", $channel)),
coord(concat!($prefix, "p1_y"), "param.curve.p1_y", 0.25)
.faceted(facet_of("param.curve.p1_y", $channel)),
coord(concat!($prefix, "p2_x"), "param.curve.p2_x", 0.5)
.faceted(facet_of("param.curve.p2_x", $channel)),
coord(concat!($prefix, "p2_y"), "param.curve.p2_y", 0.5)
.faceted(facet_of("param.curve.p2_y", $channel)),
coord(concat!($prefix, "p3_x"), "param.curve.p3_x", 0.75)
.faceted(facet_of("param.curve.p3_x", $channel)),
coord(concat!($prefix, "p3_y"), "param.curve.p3_y", 0.75)
.faceted(facet_of("param.curve.p3_y", $channel)),
coord(concat!($prefix, "p4_x"), "param.curve.p4_x", 1.0)
.faceted(facet_of("param.curve.p4_x", $channel)),
coord(concat!($prefix, "p4_y"), "param.curve.p4_y", 1.0)
.faceted(facet_of("param.curve.p4_y", $channel)),
)*]
};
}
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
// Both, and this is the case the plural exists for: the master curve is
// tonal and the per-channel curves are chromatic. Filing it under one
// would hide it from half the people looking for it.
attributes: vec![Attribute::Tone, Attribute::Colour],
id: ID,
label: LocalizedKey("op.tone_curve"),
// Defaults lie on y = x, so a fresh curve is the identity and the
// operation reports itself inactive — on every channel.
//
// The master's ten come first, and stay first: a frontend addresses a
// point by its offset from the first parameter of the run it is drawing,
// and this is also the order one falling back to sliders reads them in.
params: channel_params![
("", Channel::Master),
("r_", Channel::Red),
("g_", Channel::Green),
("b_", Channel::Blue),
],
})
});
/// One span of a monotone cubic Hermite spline. Shared by all four curves.
const CURVE_SPAN: Helper = Helper {
name: "curve_span",
source: "\
// One span of a monotone cubic Hermite spline.
//
// Takes the span's endpoints and the secants either side of it, rather than
// an array and an index. **No dynamic indexing anywhere in this file**:
// indexing a `array<f32, 5>` by a runtime value made RADV (Mesa 26.1) crash
// the process with SIGSEGV during pipeline creation, not merely fail to
// compile. Five points means four spans, so unrolling costs a short branch
// chain and removes the hazard entirely.
fn curve_span(
x0: f32, y0: f32, x1: f32, y1: f32,
s_prev: f32, s_next: f32, x: f32,
) -> f32 {
let h = x1 - x0;
let secant = (y1 - y0) / h;
// Tangents: the average of the adjoining secants, but zero wherever the
// data turns, which is what pins a local extremum in place.
var m0 = 0.5 * (s_prev + secant);
var m1 = 0.5 * (secant + s_next);
if (s_prev * secant <= 0.0) { m0 = 0.0; }
if (secant * s_next <= 0.0) { m1 = 0.0; }
if (abs(secant) < 0.000001) {
// A flat span must stay flat.
m0 = 0.0;
m1 = 0.0;
} else {
// The Fritsch-Carlson (1980) limiter: cap each tangent at three
// times the secant. This is what prevents overshoot — an
// unconstrained spline can dip below a point's neighbour, inverting
// tones and putting a dark halo through a smooth gradient.
let a = m0 / secant;
let b = m1 / secant;
let magnitude = a * a + b * b;
if (magnitude > 9.0) {
let scale = 3.0 / sqrt(magnitude);
m0 = scale * a * secant;
m1 = scale * b * secant;
}
}
// Cubic Hermite basis on the normalised span.
let t = (x - x0) / h;
let t2 = t * t;
let t3 = t2 * t;
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
let h10 = t3 - 2.0 * t2 + t;
let h01 = -2.0 * t3 + 3.0 * t2;
let h11 = t3 - t2;
return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1;
}",
};
/// The five-point evaluation. Shared by all four curves — one function, called
/// with whichever curve's points the caller holds, rather than four copies
/// that could be improved one at a time.
const CURVE_EVAL: Helper = Helper {
name: "curve_eval",
source: "\
// Evaluate a five-point curve at `x`.
//
// Spans are unrolled and secants passed explicitly; see `curve_span` for why
// there is no array indexing here. Points arrive pre-sorted with a minimum
// separation enforced on the CPU — per curve, so one channel's points cannot
// be rescued by another's — so no division can be by zero.
fn curve_eval(
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32, x: f32,
) -> f32 {
// Outside the point range the curve is flat, matching how the endpoints
// read in the widget: nothing exists beyond them to interpolate toward.
if (x <= x0) { return y0; }
if (x >= x4) { return y4; }
let s0 = (y1 - y0) / (x1 - x0);
let s1 = (y2 - y1) / (x2 - x1);
let s2 = (y3 - y2) / (x3 - x2);
let s3 = (y4 - y3) / (x4 - x3);
// The outermost secants are duplicated, so the boundary tangents match
// the span they adjoin.
if (x < x1) { return curve_span(x0, y0, x1, y1, s0, s1, x); }
if (x < x2) { return curve_span(x1, y1, x2, y2, s0, s2, x); }
if (x < x3) { return curve_span(x2, y2, x3, y3, s1, s3, x); }
return curve_span(x3, y3, x4, y4, s2, s3, x);
}",
};
/// TRACES: FR-DEV-2
/// The curve continued past its last point, for scene values above the
/// widget's axis.
const CURVE_EXTEND: Helper = Helper {
name: "curve_extend",
source: "\
// A five-point curve at `x`, continued past its last point along the slope of
// its last span.
//
// The widget draws a 0..1 axis, and scene-referred values do not stop at 1
// (D19): exposure and highlight recovery put them above it, and the view
// transform after every operation is what brings them down. Flat past the last
// point — which is what `curve_eval` gives, and what this curve did until
// D19 — made every one of them the same number, a hard clip in the middle of
// the chain. Continued along the last span instead, an identity curve stays
// the identity to any height, and a curve that lifts the highlights keeps
// lifting them. The slope is the last span's secant, which is also the
// tangent `curve_eval` gives the last point, so the join is smooth; monotone
// points make it non-negative.
fn curve_extend(
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32, x: f32,
) -> f32 {
if (x <= x4) {
return curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, x);
}
return y4 + (x - x4) * max((y4 - y3) / (x4 - x3), 0.0);
}",
};
/// One colour component through its own curve.
const CHANNEL_CURVE: Helper = Helper {
name: "channel_curve",
source: "\
// One colour component through its own curve, on the display-referred axis.
//
// The same encode-curve-decode as the master's, and for the same reason: the
// widget draws a 0..1 grid, so a point placed at the middle of it has to mean
// the middle of the visible range rather than the middle of an unbounded
// scene-referred one.
//
// What differs is that this is applied to the component *directly* rather than
// as a ratio over luminance. That is the whole point of a per-channel curve —
// it changes the proportions between the components, which is what makes it
// chromatic where the master is tonal.
//
// Above the axis the curve continues along its last span (`curve_extend`),
// exactly as the master's does, so a highlight is tinted the way every tone
// just below it is — a component that stopped at the curve's top instead
// would put a coloured fringe along a blown edge, and flattened every
// scene-referred highlight into one value besides (D19). Below zero there is
// no light to curve; the floor is the one clamp left, and it is at zero, not
// at one.
fn channel_curve(
v: f32,
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
x3: f32, y3: f32, x4: f32, y4: f32,
) -> f32 {
let encoded = pow(max(v, 0.0), 1.0 / 2.2);
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
return pow(max(curved, 0.0), 2.2);
}",
};
// The three helper sets, one per shape of edit.
//
// Chosen rather than assembled because [`Operation::helpers`] hands back a
// `&'static [Helper]` and there is nowhere to build a list at call time. Three
// statics rather than one union so a master-only edit — the common case —
// declares no function it does not call, and a grade with no tonal work does
// not drag in the luminance machinery it has no use for.
/// The master curve alone.
static MASTER_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
helpers::APPLY_TONE_GAIN,
CURVE_SPAN,
CURVE_EVAL,
CURVE_EXTEND,
];
/// The per-channel curves alone.
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CURVE_EXTEND, CHANNEL_CURVE];
/// Both.
static ALL_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
helpers::APPLY_TONE_GAIN,
CURVE_SPAN,
CURVE_EVAL,
CURVE_EXTEND,
CHANNEL_CURVE,
];
/// The master curve's fragment: tone, applied as a ratio so hue survives it.
const MASTER_BODY: &str = "\
let luma = luminance(c);
if (luma > 0.0001) {
// The curve is authored on a 0..1 axis, which is where the widget's grid
// lives, with a 2.2 gamma so that a point placed at the middle of the
// grid means the middle of the visible range. Scene-referred luminance
// does not stop at 1: above the axis the curve continues along its last
// span (`curve_extend`) rather than clipping, because the view transform
// after every operation is what brings a highlight down (D19).
let encoded = pow(luma, 1.0 / 2.2);
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
let decoded = pow(max(curved, 0.0), 2.2);
// Applied as a ratio so hue is preserved, exactly as contrast does.
c = apply_tone_gain(c, decoded / luma);
}";
/// A five-point monotone spline.
///
/// One of these per channel. The point *values* live here and the parameter
/// *names* live in [`CURVE_PARAMS`], which is what lets the master keep the
/// ids it was born with while the code below stops caring which curve it is
/// holding.
#[derive(Debug, Clone, Copy)]
struct Curve {
xs: [f32; POINTS],
ys: [f32; POINTS],
}
impl Curve {
/// The identity diagonal.
const fn identity() -> Self {
let mut xs = [0.0f32; POINTS];
let mut ys = [0.0f32; POINTS];
let mut i = 0;
while i < POINTS {
let t = i as f32 / (POINTS - 1) as f32;
xs[i] = t;
ys[i] = t;
i += 1;
}
Self { xs, ys }
}
/// The x coordinates, sorted and separated.
///
/// The widget cannot reorder points, but a sidecar can carry anything and
/// a spline through unordered or coincident x values divides by zero.
/// Enforced here so the shader never has to check — and enforced on each
/// curve independently, because a NaN on the blue channel blanks the image
/// exactly as thoroughly as one on the master, and it is the one nobody
/// thinks to try.
fn sorted_xs(&self) -> [f32; POINTS] {
const MIN_GAP: f32 = 0.001;
let mut xs = self.xs;
// Insertion sort: five elements, and it keeps the pairing with ys
// simple to reason about at the call site.
for i in 1..POINTS {
let mut j = i;
while j > 0 && xs[j - 1] > xs[j] {
xs.swap(j - 1, j);
j -= 1;
}
}
// Push apart any coincident pair, left to right.
for i in 1..POINTS {
if xs[i] - xs[i - 1] < MIN_GAP {
xs[i] = xs[i - 1] + MIN_GAP;
}
}
xs
}
/// Whether this curve differs from the identity.
fn differs_from_identity(&self) -> bool {
self.xs
.iter()
.zip(self.ys.iter())
.any(|(x, y)| (x - y).abs() > 1e-6)
}
}
/// TRACES: FR-DEV-3
/// A master tone curve and one curve per colour channel.
#[derive(Debug, Clone)]
pub struct ToneCurve {
/// Indexed by [`Channel::index`].
curves: [Curve; CHANNELS],
}
impl Default for ToneCurve {
fn default() -> Self {
Self {
curves: [Curve::identity(); CHANNELS],
}
}
}
impl ToneCurve {
pub fn new() -> Self {
Self::default()
}
/// TRACES: FR-CAT-8
/// Map a parameter id to `(channel, point index, is_y)`.
///
/// **An id with no channel prefix is the master curve**, which is what
/// makes a sidecar written before the per-channel curves existed load and
/// mean what it meant: `p2_y` was the master's third point then and parses
/// to the master's third point now. Nothing needs a version check, because
/// nothing was renamed — the new curves took new names instead.
///
/// Only the three known prefixes are recognised, so an id from a *newer*
/// build naming a curve this one does not have falls out as `None` and is
/// warned about, rather than being read as some other point. The sidecar
/// preserves the line either way (see [`crate::sidecar`]), so the edit
/// survives the round trip through a build that cannot apply it.
fn index_of(id: ParamId) -> Option<(Channel, usize, bool)> {
let (channel, rest) = match id.0.split_once('_') {
Some(("r", rest)) => (Channel::Red, rest),
Some(("g", rest)) => (Channel::Green, rest),
Some(("b", rest)) => (Channel::Blue, rest),
// No recognised prefix: the id names the master's own point, in
// the spelling it has always had.
_ => (Channel::Master, id.0),
};
let (point, axis) = rest.split_once('_')?;
let index: usize = point.strip_prefix('p')?.parse().ok()?;
if index >= POINTS {
return None;
}
match axis {
"x" => Some((channel, index, false)),
"y" => Some((channel, index, true)),
_ => None,
}
}
fn curve(&self, channel: Channel) -> &Curve {
&self.curves[channel.index()]
}
/// Whether any per-channel curve contributes anything.
fn channels_active(&self) -> bool {
Channel::ALL
.iter()
.filter(|c| c.component().is_some())
.any(|c| self.curve(*c).differs_from_identity())
}
}
impl Operation for ToneCurve {
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match Self::index_of(id) {
Some((c, i, true)) => self.curves[c.index()].ys[i] = value,
Some((c, i, false)) => self.curves[c.index()].xs[i] = value,
None => log::warn!("tone_curve: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match Self::index_of(id) {
Some((c, i, true)) => self.curve(c).ys[i],
Some((c, i, false)) => self.curve(c).xs[i],
None => 0.0,
}
}
fn is_active(&self) -> bool {
self.curves.iter().any(Curve::differs_from_identity)
}
fn presentation(&self) -> Option<Presentation> {
Some(Presentation {
// One entry: there is no second way to draw a tone curve that is
// better than the sliders the frontend falls back to anyway.
widgets: vec![WidgetKind::ToneCurve],
demand: WidgetDemand {
// A point is dragged in x and y together — that is what a
// curve *is*, and a frontend that can only move one axis at a
// time is better off with the point coordinates as sliders.
two_dimensional: true,
precise_pointing: true,
},
// All four curves. A widget claiming only the master's ten would
// leave the other thirty stranded as sliders beneath the plot;
// which of the four it draws at a time is its own affair, and the
// facets are what let it decide without naming a channel.
params: CURVE_PARAMS.to_vec(),
})
}
fn wgsl_body(&self) -> String {
let mut body = String::new();
// Tone first, then colour on top of it — see the module documentation
// for why this order and not the other one.
if self.curve(Channel::Master).differs_from_identity() {
body.push_str(MASTER_BODY);
body.push('\n');
}
for channel in Channel::ALL {
let Some(component) = channel.component() else {
continue;
};
// An untouched channel is not a curve evaluated to the identity;
// it is nothing at all in the generated source.
if !self.curve(channel).differs_from_identity() {
continue;
}
// The arguments come out of the same table the uniforms are
// declared from, so a call and its uniform block cannot disagree
// about a name.
let args = UNIFORM_NAMES[channel.index()].join(", ");
let _ = writeln!(
body,
"c.{component} = channel_curve(c.{component}, {args});"
);
}
// Whichever curves ran, the result has to be a colour: the spline's
// tangents can carry a point at the floor a very small distance below
// zero, and a negative component poisons every operation after this
// one.
body.push_str("c = max(c, vec3<f32>(0.0));");
body
}
fn uniforms(&self) -> Vec<Uniform> {
let mut out = Vec::new();
for channel in Channel::ALL {
let curve = self.curve(channel);
// An untouched curve declares nothing, which is what makes three
// unused curves cost nothing rather than thirty uniform slots.
if !curve.differs_from_identity() {
continue;
}
let names = &UNIFORM_NAMES[channel.index()];
let xs = curve.sorted_xs();
for i in 0..POINTS {
out.push(Uniform {
name: names[i * 2],
value: xs[i],
});
out.push(Uniform {
name: names[i * 2 + 1],
value: curve.ys[i],
});
}
}
out
}
fn helpers(&self) -> &'static [Helper] {
match (
self.curve(Channel::Master).differs_from_identity(),
self.channels_active(),
) {
(true, false) => MASTER_HELPERS,
(false, true) => CHANNEL_HELPERS,
// Both — and the fourth case, neither, which the composer never
// asks because an inactive operation is skipped whole.
_ => ALL_HELPERS,
}
}
}
/// Evaluate a curve on the CPU.
///
/// The same maths as the shader, used by the widget to draw the line it is
/// editing. Duplicating it is deliberate: the alternative is a GPU readback
/// per frame to draw a 200-pixel polyline (ARCH §6.1), and the shared tests
/// below pin the two implementations to the same values.
///
/// Takes points rather than a channel because it has no idea which curve it is
/// drawing and does not need one — four curves are four calls.
pub fn evaluate(xs: &[f32; POINTS], ys: &[f32; POINTS], x: f32) -> f32 {
if x <= xs[0] {
return ys[0];
}
if x >= xs[POINTS - 1] {
return ys[POINTS - 1];
}
let mut i = 0;
for k in (1..POINTS - 1).rev() {
if x >= xs[k] {
i = k;
break;
}
}
let (x0, x1) = (xs[i], xs[i + 1]);
let (y0, y1) = (ys[i], ys[i + 1]);
let h = x1 - x0;
let secant = (y1 - y0) / h;
let s_prev = if i > 0 {
(ys[i] - ys[i - 1]) / (xs[i] - xs[i - 1])
} else {
secant
};
let s_next = if i + 2 <= POINTS - 1 {
(ys[i + 2] - ys[i + 1]) / (xs[i + 2] - xs[i + 1])
} else {
secant
};
let mut m0 = 0.5 * (s_prev + secant);
let mut m1 = 0.5 * (secant + s_next);
if s_prev * secant <= 0.0 {
m0 = 0.0;
}
if secant * s_next <= 0.0 {
m1 = 0.0;
}
if secant.abs() < 1e-6 {
m0 = 0.0;
m1 = 0.0;
} else {
let a = m0 / secant;
let b = m1 / secant;
let magnitude = a * a + b * b;
if magnitude > 9.0 {
let scale = 3.0 / magnitude.sqrt();
m0 = scale * a * secant;
m1 = scale * b * secant;
}
}
let t = (x - x0) / h;
let (t2, t3) = (t * t, t * t * t);
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
let h10 = t3 - 2.0 * t2 + t;
let h01 = -2.0 * t3 + 3.0 * t2;
let h11 = t3 - t2;
h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1
}
#[cfg(test)]
mod tests {
use super::*;
fn identity() -> ([f32; POINTS], [f32; POINTS]) {
let c = Curve::identity();
(c.xs, c.ys)
}
/// The three curves that are not the master.
const COLOURS: [Channel; 3] = [Channel::Red, Channel::Green, Channel::Blue];
#[test]
fn a_fresh_curve_is_the_identity_and_inactive() {
// Opening an unedited image must show the image.
let c = ToneCurve::new();
assert!(!c.is_active());
for p in &DESCRIPTOR.params {
assert_eq!(c.param(p.id), p.default);
}
}
#[test]
fn the_identity_curve_returns_its_input() {
let (xs, ys) = identity();
for i in 0..=20 {
let x = i as f32 / 20.0;
let y = evaluate(&xs, &ys, x);
assert!((y - x).abs() < 1e-4, "identity curve at {x} returned {y}");
}
}
#[test]
fn every_parameter_id_maps_to_a_point() {
for p in &DESCRIPTOR.params {
assert!(
ToneCurve::index_of(p.id).is_some(),
"{} does not map to a point",
p.id
);
}
assert_eq!(DESCRIPTOR.params.len(), CHANNELS * POINTS * 2);
}
#[test]
fn the_ids_match_the_channel_prefixes() {
// `concat!` cannot call `Channel::prefix`, so the prefixes are written
// twice. This is what stops the two spellings drifting apart — which
// would produce a parameter the descriptor declares and `index_of`
// routes somewhere else.
for channel in Channel::ALL {
for (i, id) in channel.params().iter().enumerate() {
assert!(
id.0.starts_with(channel.prefix()),
"{id} is not on {channel:?}"
);
let point = i / 2;
let is_y = i % 2 == 1;
assert_eq!(ToneCurve::index_of(*id), Some((channel, point, is_y)));
}
}
}
#[test]
fn the_master_curves_parameters_are_spelled_as_they_always_were() {
// **The sidecar compatibility test.** These ten ids are written into
// every file produced before the per-channel curves existed, and a
// sidecar is the authoritative store of an edit (ARCH §6.12).
// Renaming one — to `m_p2_y`, say, for symmetry with `r_p2_y` — would
// silently drop that point from every edit in the field.
for (i, (x, y)) in [
(P0_X, P0_Y),
(P1_X, P1_Y),
(P2_X, P2_Y),
(P3_X, P3_Y),
(P4_X, P4_Y),
]
.into_iter()
.enumerate()
{
assert_eq!(ToneCurve::index_of(x), Some((Channel::Master, i, false)));
assert_eq!(ToneCurve::index_of(y), Some((Channel::Master, i, true)));
assert_eq!(coordinate(Channel::Master, i, Axis::X), x);
assert_eq!(coordinate(Channel::Master, i, Axis::Y), y);
assert!(
DESCRIPTOR.params.iter().any(|p| p.id == x),
"{x} left the descriptor"
);
}
}
#[test]
fn a_master_point_from_an_older_sidecar_still_moves_the_master_curve() {
// The same claim from the other end: the *value* arrives where it used
// to, not merely the name.
let mut c = ToneCurve::new();
c.set_param(ParamId("p2_y"), 0.65);
assert_eq!(c.curve(Channel::Master).ys[2], 0.65);
for channel in COLOURS {
assert!(
!c.curve(channel).differs_from_identity(),
"{channel:?} moved when only the master was set"
);
}
}
#[test]
fn each_channel_owns_its_own_points() {
// The failure this guards is one array behind four names: set red,
// read blue, and see red's value.
let mut c = ToneCurve::new();
for (channel, value) in COLOURS.into_iter().zip([0.6, 0.7, 0.8]) {
c.set_param(coordinate(channel, 2, Axis::Y), value);
}
assert_eq!(c.param(coordinate(Channel::Red, 2, Axis::Y)), 0.6);
assert_eq!(c.param(coordinate(Channel::Green, 2, Axis::Y)), 0.7);
assert_eq!(c.param(coordinate(Channel::Blue, 2, Axis::Y)), 0.8);
assert_eq!(c.param(P2_Y), 0.5, "the master must not have moved");
}
#[test]
fn moving_a_point_activates_the_curve() {
let mut c = ToneCurve::new();
c.set_param(P2_Y, 0.65);
assert!(c.is_active());
assert_eq!(c.param(P2_Y), 0.65);
}
#[test]
fn moving_a_channel_point_activates_the_operation() {
// An edit that touches only the blue curve is still an edit; an
// `is_active` that looked at the master alone would drop it from the
// shader and show the untouched image.
for channel in COLOURS {
let mut c = ToneCurve::new();
c.set_param(coordinate(channel, 1, Axis::Y), 0.4);
assert!(c.is_active(), "{channel:?} did not activate the operation");
}
}
#[test]
fn the_curve_passes_through_its_control_points() {
// The property that makes the widget honest: the line drawn through
// a point must actually reach it.
let mut c = ToneCurve::new();
c.set_param(P1_Y, 0.15);
c.set_param(P3_Y, 0.85);
let master = c.curve(Channel::Master);
let xs = master.sorted_xs();
for i in 0..POINTS {
let y = evaluate(&xs, &master.ys, xs[i]);
assert!(
(y - master.ys[i]).abs() < 1e-4,
"point {i} at x={} evaluated to {y}, expected {}",
xs[i],
master.ys[i]
);
}
}
#[test]
fn an_s_curve_stays_monotonic() {
// The reason for Fritsch-Carlson. An unconstrained spline through
// these points overshoots, dipping below a neighbour and inverting
// tones — visible as a dark halo in a smooth gradient.
let mut c = ToneCurve::new();
c.set_param(P1_Y, 0.10);
c.set_param(P3_Y, 0.90);
let master = c.curve(Channel::Master);
let xs = master.sorted_xs();
let mut previous = f32::NEG_INFINITY;
for i in 0..=200 {
let x = i as f32 / 200.0;
let y = evaluate(&xs, &master.ys, x);
assert!(
y >= previous - 1e-5,
"curve decreased at x={x}: {y} after {previous}"
);
previous = y;
}
}
#[test]
fn an_extreme_curve_stays_monotonic() {
// Every point dragged to a limit — what a user does when exploring
// what a control can do.
let mut c = ToneCurve::new();
c.set_param(P0_Y, 0.0);
c.set_param(P1_Y, 0.95);
c.set_param(P2_Y, 0.96);
c.set_param(P3_Y, 0.97);
c.set_param(P4_Y, 1.0);
let master = c.curve(Channel::Master);
let xs = master.sorted_xs();
let mut previous = f32::NEG_INFINITY;
for i in 0..=200 {
let y = evaluate(&xs, &master.ys, i as f32 / 200.0);
assert!(y >= previous - 1e-5, "decreased at {i}");
assert!(y.is_finite(), "non-finite at {i}");
previous = y;
}
}
#[test]
fn a_channel_curve_stays_monotonic() {
// The same guarantee the master carries, and it matters more here: a
// non-monotone blue curve inverts blue locally, which is a hue
// reversal rather than a dark halo — harder to see and much harder to
// attribute to the control that caused it.
let mut c = ToneCurve::new();
c.set_param(coordinate(Channel::Blue, 1, Axis::Y), 0.05);
c.set_param(coordinate(Channel::Blue, 3, Axis::Y), 0.95);
let blue = c.curve(Channel::Blue);
let xs = blue.sorted_xs();
let mut previous = f32::NEG_INFINITY;
for i in 0..=200 {
let y = evaluate(&xs, &blue.ys, i as f32 / 200.0);
assert!(y >= previous - 1e-5, "blue decreased at {i}");
previous = y;
}
}
#[test]
fn a_flat_span_stays_flat() {
// Two points at the same height must not bow between them.
let mut c = ToneCurve::new();
c.set_param(P1_Y, 0.5);
c.set_param(P2_Y, 0.5);
c.set_param(P3_Y, 0.5);
let master = c.curve(Channel::Master);
let xs = master.sorted_xs();
for i in 0..=20 {
let x = 0.25 + (i as f32 / 20.0) * 0.5;
let y = evaluate(&xs, &master.ys, x);
assert!((y - 0.5).abs() < 1e-4, "at {x} the flat span gave {y}");
}
}
#[test]
fn the_curve_is_clamped_outside_its_endpoints() {
let (xs, ys) = identity();
assert_eq!(evaluate(&xs, &ys, -1.0), ys[0]);
assert_eq!(evaluate(&xs, &ys, 2.0), ys[POINTS - 1]);
}
#[test]
fn coincident_x_values_are_separated_on_every_channel() {
// A sidecar can carry anything; a spline through two points at the
// same x divides by zero and produces NaN across the image. The
// guarantee has to hold per curve, because the sort is per curve.
for channel in Channel::ALL {
let mut c = ToneCurve::new();
for point in 1..4 {
c.set_param(coordinate(channel, point, Axis::X), 0.5);
}
let curve = c.curve(channel);
let xs = curve.sorted_xs();
for i in 1..POINTS {
assert!(
xs[i] > xs[i - 1],
"{channel:?}: x values must be strictly increasing, got {xs:?}"
);
}
// And the result must be usable, not merely non-crashing.
for i in 0..=50 {
assert!(evaluate(&xs, &curve.ys, i as f32 / 50.0).is_finite());
}
}
}
#[test]
fn out_of_order_x_values_are_sorted_on_every_channel() {
for channel in Channel::ALL {
let mut c = ToneCurve::new();
c.set_param(coordinate(channel, 1, Axis::X), 0.9);
c.set_param(coordinate(channel, 3, Axis::X), 0.1);
let xs = c.curve(channel).sorted_xs();
for i in 1..POINTS {
assert!(xs[i] > xs[i - 1], "{channel:?} not sorted: {xs:?}");
}
}
}
#[test]
fn the_widget_owns_every_point_parameter() {
// If the presentation misses one, that slider appears twice: once in
// the curve and once as a stray control beneath it.
let presentation = ToneCurve::new().presentation().expect("declares a widget");
assert_eq!(presentation.widgets, &[WidgetKind::ToneCurve]);
// A frontend that implements the curve gets it; one that implements
// nothing falls through to sliders rather than to an error.
assert_eq!(
presentation.choose(|w| w == WidgetKind::ToneCurve),
Some(WidgetKind::ToneCurve)
);
assert_eq!(presentation.choose(|_| false), None);
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
for p in &DESCRIPTOR.params {
assert!(
presentation.params.contains(&p.id),
"{} is not owned by the widget",
p.id
);
}
}
#[test]
fn the_widgets_parameters_are_grouped_by_the_curve_they_belong_to() {
// What a channel selector is built out of. A panel groups the widget's
// parameters by their facet's subject and gets four curves, in this
// order, without knowing that a colour channel is a thing — so each
// channel's run has to be contiguous, complete, and labelled.
let presentation = ToneCurve::new().presentation().expect("declares a widget");
for channel in Channel::ALL {
let base = channel.index() * POINTS * 2;
assert_eq!(
&presentation.params[base..base + POINTS * 2],
channel.params(),
"{channel:?}'s points are not contiguous in the widget's list"
);
for id in channel.params() {
let facet = DESCRIPTOR
.param(*id)
.expect("declared")
.facet
.expect("a curve point says which curve it is on");
assert_eq!(facet.subject, channel.subject());
assert_eq!(facet.subject_hue, channel.hue());
}
}
}
#[test]
fn the_four_curves_share_one_aspect_per_coordinate() {
// The other half of the grid: the same point on all four curves is one
// control applied to four subjects, which is what makes a panel able to
// draw one plot and change its subject.
for point in 0..POINTS {
for axis in [Axis::X, Axis::Y] {
let aspects: Vec<_> = Channel::ALL
.iter()
.map(|c| {
DESCRIPTOR
.param(coordinate(*c, point, axis))
.expect("declared")
.facet
.expect("faceted")
.aspect
})
.collect();
assert!(
aspects.windows(2).all(|w| w[0] == w[1]),
"point {point} {axis:?} does not share an aspect across the curves"
);
}
}
}
#[test]
fn the_fragment_reads_every_declared_uniform() {
let mut c = ToneCurve::new();
c.set_param(P2_Y, 0.7);
for channel in COLOURS {
c.set_param(coordinate(channel, 2, Axis::Y), 0.6);
}
let body = c.wgsl_body();
for u in c.uniforms() {
assert!(
body.contains(u.name),
"uniform {} is declared but never read",
u.name
);
}
}
#[test]
fn an_untouched_channel_costs_nothing() {
// **The property that makes four curves affordable.** A photograph
// edited with the master curve alone must generate what it generated
// when this operation held one curve: the same ten uniforms, the same
// fragment, and not one line about red, green or blue.
let mut c = ToneCurve::new();
c.set_param(P2_Y, 0.7);
let body = c.wgsl_body();
assert!(
body.contains("curve_extend("),
"the master curve is missing"
);
assert!(
!body.contains("channel_curve("),
"an untouched channel reached the shader:\n{body}"
);
let names: Vec<&str> = c.uniforms().iter().map(|u| u.name).collect();
assert_eq!(names.len(), POINTS * 2, "only the master declares uniforms");
assert!(
!names.iter().any(|n| n.contains('_')),
"an untouched channel declared uniforms: {names:?}"
);
assert_eq!(c.helpers(), MASTER_HELPERS);
}
#[test]
fn an_untouched_master_costs_nothing() {
// The mirror image, and the case a naive implementation gets wrong: a
// grade with no tonal work should not pay for a luminance evaluation
// that maps every pixel to itself.
let mut c = ToneCurve::new();
c.set_param(coordinate(Channel::Blue, 0, Axis::Y), 0.08);
let body = c.wgsl_body();
assert!(
!body.contains("luminance("),
"the identity master curve reached the shader:\n{body}"
);
assert!(body.contains("c.b = channel_curve(c.b,"));
assert!(!body.contains("c.r = "), "red was untouched:\n{body}");
let names: Vec<&str> = c.uniforms().iter().map(|u| u.name).collect();
assert_eq!(names.len(), POINTS * 2);
assert!(names.iter().all(|n| n.starts_with("b_")), "{names:?}");
assert_eq!(c.helpers(), CHANNEL_HELPERS);
}
#[test]
fn a_neutral_curve_contributes_no_uniforms_at_all() {
// Belt and braces around `is_active`: the composer skips an inactive
// operation, but one that declared uniforms while claiming to be
// neutral would push the whole uniform block out of step the day that
// changed.
let c = ToneCurve::new();
assert!(!c.is_active());
assert!(c.uniforms().is_empty());
assert_eq!(c.wgsl_body(), "c = max(c, vec3<f32>(0.0));");
}
#[test]
fn the_master_curve_runs_before_the_channel_curves() {
// **The composition order, asserted rather than described.** The
// channels grade the tones the master produced; the other order is a
// visibly different image, and it is the kind of change that arrives
// by accident when someone reorders a loop.
let mut c = ToneCurve::new();
c.set_param(P2_Y, 0.7);
c.set_param(coordinate(Channel::Red, 1, Axis::Y), 0.3);
let body = c.wgsl_body();
let master = body.find("apply_tone_gain").expect("the master curve runs");
let red = body
.find("c.r = channel_curve")
.expect("the red curve runs");
assert!(master < red, "the master curve must run first:\n{body}");
}
#[test]
fn the_channels_run_in_the_order_they_are_listed() {
// Not because the result depends on it — the three act on separate
// components — but because a reader comparing the generated shader
// with this file should not have to wonder whether it does.
let mut c = ToneCurve::new();
for channel in COLOURS {
c.set_param(coordinate(channel, 2, Axis::Y), 0.6);
}
let body = c.wgsl_body();
let at = |s: &str| body.find(s).unwrap_or_else(|| panic!("{s} missing"));
assert!(at("c.r = ") < at("c.g = "));
assert!(at("c.g = ") < at("c.b = "));
}
#[test]
fn unknown_parameters_are_ignored() {
let mut c = ToneCurve::new();
c.set_param(ParamId("p9_x"), 0.5);
c.set_param(ParamId("nonsense"), 0.5);
c.set_param(ParamId("p1_z"), 0.5);
// A curve from a build that has more of them than this one does.
c.set_param(ParamId("k_p1_y"), 0.5);
c.set_param(ParamId("r_p9_y"), 0.5);
assert!(!c.is_active());
}
#[test]
fn every_channel_is_nameable_and_distinct() {
// The selector is built from these, so two channels sharing a key
// would draw two entries with one name and no way to tell which is
// which.
let mut keys: Vec<&str> = Channel::ALL.iter().map(|c| c.subject().0).collect();
let before = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(before, keys.len(), "two channels share a name");
assert_eq!(Channel::ALL.len(), CHANNELS);
for c in Channel::ALL {
assert!(!c.subject().0.is_empty());
}
}
}