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