Files
DarkRoom/core/dr-pipeline/src/ops/curve.rs
T
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00

1395 lines
54 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 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 a `ParamDescriptor` has to
/// be `const` to live in a `static`, and a const loop cannot build a slice.
macro_rules! channel_params {
($(($prefix:literal, $channel:expr)),* $(,)?) => {
&[$(
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: OpDescriptor = 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: &[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);
}",
};
/// 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.
//
// The clamp is the curve's promise rather than an oversight: its last point
// *is* white, so a component arriving above the axis takes the value the curve
// gives at 1. The master does the same to a luminance above 1, through the
// gain it applies; a channel curve that instead let highlights past unchanged
// would tint them differently from every tone below them, which reads as a
// coloured fringe along a blown edge.
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(clamp(v, 0.0, 1.0), 1.0 / 2.2);
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
return pow(clamp(curved, 0.0, 1.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,
];
/// The per-channel curves alone.
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CHANNEL_CURVE];
/// Both.
static ALL_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
helpers::APPLY_TONE_GAIN,
CURVE_SPAN,
CURVE_EVAL,
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 display-referred 0..1 axis, which is where
// the eye reads tone and where the widget's grid lives. Scene-referred
// luminance is unbounded, so it is encoded to that axis, curved, and
// decoded back — otherwise a point placed at the middle of the grid
// would not correspond to the middle of the visible range.
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2);
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
let decoded = pow(clamp(curved, 0.0, 1.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) -> &'static OpDescriptor {
&DESCRIPTOR
}
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: &[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,
})
}
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_eval("), "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());
}
}
}