`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>
1395 lines
54 KiB
Rust
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());
|
|
}
|
|
}
|
|
}
|