Skip to main content

proof_engine/math/
color.rs

1//! Color science utilities: color spaces, palettes, gradients, LUT generation.
2//!
3//! Provides conversion between linear RGB, sRGB, HSV, HSL, Oklab, CIE Lab,
4//! CIE LCH, and XYZ color spaces. Also includes gradient building, palette
5//! generation, color harmonies, and LUT support.
6
7#![warn(missing_docs)]
8
9use glam::{Vec3, Vec4};
10use std::f32::consts::PI;
11
12// ── Core color types ──────────────────────────────────────────────────────────
13
14/// Linear RGB color with alpha, all in `[0.0, 1.0]`.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct Rgba {
17    /// Red, 0.0 to 1.0.
18    pub r: f32,
19    /// Green, 0.0 to 1.0.
20    pub g: f32,
21    /// Blue, 0.0 to 1.0.
22    pub b: f32,
23    /// Alpha (opacity), 0.0 to 1.0.
24    pub a: f32,
25}
26
27impl Rgba {
28    /// Opaque white.
29    pub const WHITE:   Rgba = Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
30    /// Opaque black.
31    pub const BLACK:   Rgba = Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
32    /// Opaque red.
33    pub const RED:     Rgba = Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
34    /// Opaque green.
35    pub const GREEN:   Rgba = Rgba { r: 0.0, g: 1.0, b: 0.0, a: 1.0 };
36    /// Opaque blue.
37    pub const BLUE:    Rgba = Rgba { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
38    /// Opaque yellow.
39    pub const YELLOW:  Rgba = Rgba { r: 1.0, g: 1.0, b: 0.0, a: 1.0 };
40    /// Opaque cyan.
41    pub const CYAN:    Rgba = Rgba { r: 0.0, g: 1.0, b: 1.0, a: 1.0 };
42    /// Opaque magenta.
43    pub const MAGENTA: Rgba = Rgba { r: 1.0, g: 0.0, b: 1.0, a: 1.0 };
44    /// Fully transparent black.
45    pub const TRANSPARENT: Rgba = Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
46
47    /// A colour from red, green, blue and alpha.
48    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { Self { r, g, b, a } }
49    /// An opaque colour from red, green and blue.
50    pub fn rgb(r: f32, g: f32, b: f32) -> Self { Self { r, g, b, a: 1.0 } }
51
52    /// From a `Vec4` laid out as (r, g, b, a).
53    pub fn from_vec4(v: Vec4) -> Self { Self { r: v.x, g: v.y, b: v.z, a: v.w } }
54    /// As a `Vec4` (r, g, b, a).
55    pub fn to_vec4(self) -> Vec4 { Vec4::new(self.r, self.g, self.b, self.a) }
56    /// As a `Vec3` (r, g, b), dropping alpha.
57    pub fn to_vec3(self) -> Vec3 { Vec3::new(self.r, self.g, self.b) }
58
59    /// Construct from an `0xRRGGBB` hex literal (alpha = 1).
60    pub fn from_hex(hex: u32) -> Self {
61        let r = ((hex >> 16) & 0xFF) as f32 / 255.0;
62        let g = ((hex >> 8)  & 0xFF) as f32 / 255.0;
63        let b = ( hex        & 0xFF) as f32 / 255.0;
64        Self::rgb(r, g, b)
65    }
66
67    /// Construct from an `0xRRGGBBAA` hex literal.
68    pub fn from_hex_alpha(hex: u32) -> Self {
69        let r = ((hex >> 24) & 0xFF) as f32 / 255.0;
70        let g = ((hex >> 16) & 0xFF) as f32 / 255.0;
71        let b = ((hex >> 8)  & 0xFF) as f32 / 255.0;
72        let a = ( hex        & 0xFF) as f32 / 255.0;
73        Self { r, g, b, a }
74    }
75
76    /// The same colour with alpha replaced by `a`.
77    pub fn with_alpha(self, a: f32) -> Self { Self { a, ..self } }
78    /// Per-channel linear interpolation: `t = 0` gives `self`, `t = 1` gives `other`.
79    pub fn lerp(self, other: Rgba, t: f32) -> Self {
80        Rgba {
81            r: self.r + (other.r - self.r) * t,
82            g: self.g + (other.g - self.g) * t,
83            b: self.b + (other.b - self.b) * t,
84            a: self.a + (other.a - self.a) * t,
85        }
86    }
87
88    /// Premultiplied alpha blend: self over other.
89    pub fn over(self, other: Rgba) -> Rgba {
90        let ia = 1.0 - self.a;
91        Rgba {
92            r: self.r * self.a + other.r * ia,
93            g: self.g * self.a + other.g * ia,
94            b: self.b * self.a + other.b * ia,
95            a: self.a + other.a * ia,
96        }
97    }
98
99    /// Linear luminance (ITU-R BT.709).
100    pub fn luminance(self) -> f32 {
101        0.2126 * self.r + 0.7152 * self.g + 0.0722 * self.b
102    }
103
104    /// Convert to 8-bit RGBA tuple.
105    pub fn to_u8(self) -> [u8; 4] {
106        [
107            (self.r.clamp(0.0, 1.0) * 255.0) as u8,
108            (self.g.clamp(0.0, 1.0) * 255.0) as u8,
109            (self.b.clamp(0.0, 1.0) * 255.0) as u8,
110            (self.a.clamp(0.0, 1.0) * 255.0) as u8,
111        ]
112    }
113}
114
115impl From<Vec4> for Rgba {
116    fn from(v: Vec4) -> Self { Self::from_vec4(v) }
117}
118
119impl From<Rgba> for Vec4 {
120    fn from(c: Rgba) -> Self { c.to_vec4() }
121}
122
123// ── sRGB gamma ────────────────────────────────────────────────────────────────
124
125/// Apply sRGB gamma (linear → display).
126#[inline]
127pub fn linear_to_srgb_channel(x: f32) -> f32 {
128    if x <= 0.003_130_8 {
129        x * 12.92
130    } else {
131        1.055 * x.powf(1.0 / 2.4) - 0.055
132    }
133}
134
135/// Remove sRGB gamma (display → linear).
136#[inline]
137pub fn srgb_to_linear_channel(x: f32) -> f32 {
138    if x <= 0.040_45 {
139        x / 12.92
140    } else {
141        ((x + 0.055) / 1.055).powf(2.4)
142    }
143}
144
145/// Encode linear RGB to sRGB gamma (alpha unchanged).
146pub fn linear_to_srgb(c: Rgba) -> Rgba {
147    Rgba::new(
148        linear_to_srgb_channel(c.r),
149        linear_to_srgb_channel(c.g),
150        linear_to_srgb_channel(c.b),
151        c.a,
152    )
153}
154
155/// Decode sRGB gamma to linear RGB (alpha unchanged).
156pub fn srgb_to_linear(c: Rgba) -> Rgba {
157    Rgba::new(
158        srgb_to_linear_channel(c.r),
159        srgb_to_linear_channel(c.g),
160        srgb_to_linear_channel(c.b),
161        c.a,
162    )
163}
164
165// ── HSV ───────────────────────────────────────────────────────────────────────
166
167/// HSV color: hue in `[0, 360)`, saturation and value in `[0, 1]`.
168#[derive(Debug, Clone, Copy)]
169pub struct Hsv {
170    /// Hue in degrees, 0 to 360.
171    pub h: f32,
172    /// Saturation, 0.0 to 1.0.
173    pub s: f32,
174    /// Value (brightness), 0.0 to 1.0.
175    pub v: f32,
176}
177
178impl Hsv {
179    /// From hue (degrees), saturation and value.
180    pub fn new(h: f32, s: f32, v: f32) -> Self { Self { h, s, v } }
181
182    /// Convert to an opaque RGB colour.
183    pub fn to_rgb(self) -> Rgba {
184        let (r, g, b) = hsv_to_rgb(self.h, self.s, self.v);
185        Rgba::rgb(r, g, b)
186    }
187
188    /// Convert from RGB (alpha ignored).
189    pub fn from_rgb(c: Rgba) -> Self {
190        let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
191        Self { h, s, v }
192    }
193}
194
195/// HSV (hue in degrees, wrapped to 0..360) to `(r, g, b)`.
196pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
197    if s == 0.0 { return (v, v, v); }
198    let h = ((h % 360.0) + 360.0) % 360.0;
199    let i = (h / 60.0) as u32;
200    let f = h / 60.0 - i as f32;
201    let p = v * (1.0 - s);
202    let q = v * (1.0 - s * f);
203    let t = v * (1.0 - s * (1.0 - f));
204    match i {
205        0 => (v, t, p),
206        1 => (q, v, p),
207        2 => (p, v, t),
208        3 => (p, q, v),
209        4 => (t, p, v),
210        _ => (v, p, q),
211    }
212}
213
214/// `(r, g, b)` to HSV with hue in degrees, 0 to 360.
215pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
216    let max = r.max(g).max(b);
217    let min = r.min(g).min(b);
218    let delta = max - min;
219
220    let v = max;
221    let s = if max < 1e-8 { 0.0 } else { delta / max };
222    let h = if delta < 1e-8 {
223        0.0
224    } else if max == r {
225        60.0 * (((g - b) / delta) % 6.0)
226    } else if max == g {
227        60.0 * ((b - r) / delta + 2.0)
228    } else {
229        60.0 * ((r - g) / delta + 4.0)
230    };
231    (((h % 360.0) + 360.0) % 360.0, s, v)
232}
233
234// ── HSL ───────────────────────────────────────────────────────────────────────
235
236/// HSL color: hue in `[0, 360)`, saturation and lightness in `[0, 1]`.
237#[derive(Debug, Clone, Copy)]
238pub struct Hsl {
239    /// Hue in degrees, 0 to 360.
240    pub h: f32,
241    /// Saturation, 0.0 to 1.0.
242    pub s: f32,
243    /// Lightness, 0.0 to 1.0.
244    pub l: f32,
245}
246
247impl Hsl {
248    /// From hue (degrees), saturation and lightness.
249    pub fn new(h: f32, s: f32, l: f32) -> Self { Self { h, s, l } }
250
251    /// Convert to an opaque RGB colour.
252    pub fn to_rgb(self) -> Rgba {
253        let (r, g, b) = hsl_to_rgb(self.h, self.s, self.l);
254        Rgba::rgb(r, g, b)
255    }
256}
257
258fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 {
259    let t = ((t % 1.0) + 1.0) % 1.0;
260    if t < 1.0 / 6.0 { return p + (q - p) * 6.0 * t; }
261    if t < 1.0 / 2.0 { return q; }
262    if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }
263    p
264}
265
266/// HSL (hue in degrees) to `(r, g, b)`.
267pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
268    if s == 0.0 { return (l, l, l); }
269    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
270    let p = 2.0 * l - q;
271    let h = h / 360.0;
272    (
273        hue_to_rgb(p, q, h + 1.0 / 3.0),
274        hue_to_rgb(p, q, h),
275        hue_to_rgb(p, q, h - 1.0 / 3.0),
276    )
277}
278
279/// `(r, g, b)` to HSL with hue in degrees.
280pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
281    let max = r.max(g).max(b);
282    let min = r.min(g).min(b);
283    let l   = (max + min) * 0.5;
284    let delta = max - min;
285
286    if delta < 1e-8 { return (0.0, 0.0, l); }
287
288    let s = if l < 0.5 { delta / (max + min) } else { delta / (2.0 - max - min) };
289    let h = if max == r {
290        60.0 * ((g - b) / delta + if g < b { 6.0 } else { 0.0 })
291    } else if max == g {
292        60.0 * ((b - r) / delta + 2.0)
293    } else {
294        60.0 * ((r - g) / delta + 4.0)
295    };
296    (h, s, l)
297}
298
299// ── Oklab ────────────────────────────────────────────────────────────────────
300
301/// Oklab color: a perceptually uniform color space by Björn Ottosson.
302/// `L` = lightness \[0,1\], `a` and `b` are chroma axes (approx −0.5..0.5).
303#[derive(Debug, Clone, Copy)]
304pub struct Oklab {
305    /// Perceived lightness, 0.0 to 1.0.
306    pub l: f32,
307    /// Green (negative) to red (positive) axis.
308    pub a: f32,
309    /// Blue (negative) to yellow (positive) axis.
310    pub b: f32,
311}
312
313impl Oklab {
314    /// Convert from linear RGB (alpha ignored).
315    pub fn from_linear_rgb(c: Rgba) -> Self {
316        let l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
317        let m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
318        let s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
319
320        let l_ = l.cbrt();
321        let m_ = m.cbrt();
322        let s_ = s.cbrt();
323
324        Self {
325            l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
326            a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
327            b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
328        }
329    }
330
331    /// Convert to opaque linear RGB (may fall outside 0..1 for out-of-gamut colours).
332    pub fn to_linear_rgb(self) -> Rgba {
333        let l_ = self.l + 0.3963377774 * self.a + 0.2158037573 * self.b;
334        let m_ = self.l - 0.1055613458 * self.a - 0.0638541728 * self.b;
335        let s_ = self.l - 0.0894841775 * self.a - 1.2914855480 * self.b;
336
337        let l = l_ * l_ * l_;
338        let m = m_ * m_ * m_;
339        let s = s_ * s_ * s_;
340
341        Rgba::rgb(
342             4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
343            -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
344            -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
345        )
346    }
347
348    /// Perceptually-uniform lerp in Oklab space.
349    pub fn lerp(self, other: Oklab, t: f32) -> Oklab {
350        Oklab {
351            l: self.l + (other.l - self.l) * t,
352            a: self.a + (other.a - self.a) * t,
353            b: self.b + (other.b - self.b) * t,
354        }
355    }
356}
357
358// ── CIE XYZ ───────────────────────────────────────────────────────────────────
359
360/// CIE XYZ (D65 white point).
361#[derive(Debug, Clone, Copy)]
362pub struct Xyz {
363    /// X tristimulus value.
364    pub x: f32,
365    /// Y tristimulus value (luminance).
366    pub y: f32,
367    /// Z tristimulus value.
368    pub z: f32,
369}
370
371impl Xyz {
372    /// Convert from linear sRGB primaries (alpha ignored).
373    pub fn from_linear_rgb(c: Rgba) -> Self {
374        Self {
375            x: c.r * 0.4124 + c.g * 0.3576 + c.b * 0.1805,
376            y: c.r * 0.2126 + c.g * 0.7152 + c.b * 0.0722,
377            z: c.r * 0.0193 + c.g * 0.1192 + c.b * 0.9505,
378        }
379    }
380
381    /// Convert to opaque linear sRGB.
382    pub fn to_linear_rgb(self) -> Rgba {
383        Rgba::rgb(
384             self.x *  3.2406 + self.y * -1.5372 + self.z * -0.4986,
385             self.x * -0.9689 + self.y *  1.8758 + self.z *  0.0415,
386             self.x *  0.0557 + self.y * -0.2040 + self.z *  1.0570,
387        )
388    }
389}
390
391// ── CIE Lab ───────────────────────────────────────────────────────────────────
392
393/// CIE L*a*b* color space (D65 white point).
394#[derive(Debug, Clone, Copy)]
395pub struct Lab {
396    /// Lightness, 0 to 100.
397    pub l: f32,
398    /// Green (negative) to red (positive) axis.
399    pub a: f32,
400    /// Blue (negative) to yellow (positive) axis.
401    pub b: f32,
402}
403
404const D65_X: f32 = 0.95047;
405const D65_Y: f32 = 1.00000;
406const D65_Z: f32 = 1.08883;
407
408fn xyz_to_lab_f(t: f32) -> f32 {
409    if t > 0.008856 { t.cbrt() } else { 7.787 * t + 16.0 / 116.0 }
410}
411
412impl Lab {
413    /// Convert from XYZ relative to the D65 white point.
414    pub fn from_xyz(xyz: Xyz) -> Self {
415        let fx = xyz_to_lab_f(xyz.x / D65_X);
416        let fy = xyz_to_lab_f(xyz.y / D65_Y);
417        let fz = xyz_to_lab_f(xyz.z / D65_Z);
418        Self {
419            l: 116.0 * fy - 16.0,
420            a: 500.0 * (fx - fy),
421            b: 200.0 * (fy - fz),
422        }
423    }
424
425    /// Convert to XYZ relative to the D65 white point.
426    pub fn to_xyz(self) -> Xyz {
427        let fy = (self.l + 16.0) / 116.0;
428        let fx = self.a / 500.0 + fy;
429        let fz = fy - self.b / 200.0;
430        let cube = |v: f32| if v > 0.2069 { v * v * v } else { (v - 16.0 / 116.0) / 7.787 };
431        Xyz { x: cube(fx) * D65_X, y: cube(fy) * D65_Y, z: cube(fz) * D65_Z }
432    }
433
434    /// Convert from linear RGB (alpha ignored).
435    pub fn from_rgb(c: Rgba) -> Self {
436        Self::from_xyz(Xyz::from_linear_rgb(c))
437    }
438
439    /// Convert to opaque linear RGB.
440    pub fn to_rgb(self) -> Rgba {
441        self.to_xyz().to_linear_rgb()
442    }
443
444    /// Delta E 1976 (perceptual distance).
445    pub fn delta_e(&self, other: &Lab) -> f32 {
446        let dl = self.l - other.l;
447        let da = self.a - other.a;
448        let db = self.b - other.b;
449        (dl * dl + da * da + db * db).sqrt()
450    }
451}
452
453/// CIE LCH (Lightness, Chroma, Hue in degrees).
454#[derive(Debug, Clone, Copy)]
455pub struct Lch {
456    /// Lightness, 0 to 100.
457    pub l: f32,
458    /// Chroma (colourfulness), 0 and up.
459    pub c: f32,
460    /// Hue in degrees, 0 to 360.
461    pub h: f32,
462}
463
464impl Lch {
465    /// Convert from Lab (polar form of a and b).
466    pub fn from_lab(lab: Lab) -> Self {
467        let c = (lab.a * lab.a + lab.b * lab.b).sqrt();
468        let h = lab.b.atan2(lab.a).to_degrees();
469        let h = ((h % 360.0) + 360.0) % 360.0;
470        Self { l: lab.l, c, h }
471    }
472
473    /// Convert to Lab.
474    pub fn to_lab(self) -> Lab {
475        let h_rad = self.h.to_radians();
476        Lab { l: self.l, a: self.c * h_rad.cos(), b: self.c * h_rad.sin() }
477    }
478
479    /// Convert from linear RGB (alpha ignored).
480    pub fn from_rgb(c: Rgba) -> Self { Self::from_lab(Lab::from_rgb(c)) }
481    /// Convert to opaque linear RGB.
482    pub fn to_rgb(self) -> Rgba { self.to_lab().to_rgb() }
483
484    /// Interpolate lightness and chroma linearly and hue along the shorter
485    /// way round the colour wheel.
486    pub fn lerp_hue(self, other: Lch, t: f32) -> Lch {
487        // Shortest path around the hue circle
488        let mut dh = other.h - self.h;
489        if dh >  180.0 { dh -= 360.0; }
490        if dh < -180.0 { dh += 360.0; }
491        Lch {
492            l: self.l + (other.l - self.l) * t,
493            c: self.c + (other.c - self.c) * t,
494            h: self.h + dh * t,
495        }
496    }
497}
498
499// ── Gradient ──────────────────────────────────────────────────────────────────
500
501/// Interpolation mode for gradient stops.
502#[derive(Debug, Clone, Copy, PartialEq)]
503pub enum GradientMode {
504    /// Linear RGB interpolation.
505    LinearRgb,
506    /// Oklab interpolation (perceptually uniform, no "dark middle" artifacts).
507    Oklab,
508    /// LCH interpolation (preserves hue).
509    Lch,
510    /// HSV interpolation.
511    Hsv,
512}
513
514/// A color stop in a gradient.
515#[derive(Debug, Clone, Copy)]
516pub struct ColorStop {
517    /// Position along the gradient, 0.0 to 1.0.
518    pub t:     f32,
519    /// Colour at that position.
520    pub color: Rgba,
521}
522
523/// A multi-stop color gradient.
524#[derive(Debug, Clone)]
525pub struct Gradient {
526    /// Stops, kept sorted by `t`.
527    pub stops: Vec<ColorStop>,
528    /// Colour space used to blend between stops.
529    pub mode:  GradientMode,
530}
531
532impl Gradient {
533    /// An empty gradient that blends in `mode`.
534    pub fn new(mode: GradientMode) -> Self {
535        Self { stops: Vec::new(), mode }
536    }
537
538    /// Add a stop at `t` (clamped to 0..1) and keep the stops sorted.
539    pub fn add_stop(mut self, t: f32, color: Rgba) -> Self {
540        self.stops.push(ColorStop { t: t.clamp(0.0, 1.0), color });
541        self.stops.sort_by(|a, b| a.t.partial_cmp(&b.t).unwrap());
542        self
543    }
544
545    /// Sample the gradient at `t ∈ [0, 1]`.
546    pub fn sample(&self, t: f32) -> Rgba {
547        if self.stops.is_empty() { return Rgba::BLACK; }
548        if self.stops.len() == 1 { return self.stops[0].color; }
549
550        let t = t.clamp(0.0, 1.0);
551
552        // Find surrounding stops
553        let i = self.stops.partition_point(|s| s.t <= t);
554        if i == 0               { return self.stops[0].color; }
555        if i >= self.stops.len() { return self.stops.last().unwrap().color; }
556
557        let lo = &self.stops[i - 1];
558        let hi = &self.stops[i];
559        let f  = (t - lo.t) / (hi.t - lo.t).max(1e-8);
560
561        match self.mode {
562            GradientMode::LinearRgb => lo.color.lerp(hi.color, f),
563            GradientMode::Oklab => {
564                let a = Oklab::from_linear_rgb(lo.color);
565                let b = Oklab::from_linear_rgb(hi.color);
566                a.lerp(b, f).to_linear_rgb()
567            }
568            GradientMode::Lch => {
569                let a = Lch::from_rgb(lo.color);
570                let b = Lch::from_rgb(hi.color);
571                a.lerp_hue(b, f).to_rgb()
572            }
573            GradientMode::Hsv => {
574                let (ha, sa, va) = rgb_to_hsv(lo.color.r, lo.color.g, lo.color.b);
575                let (hb, sb, vb) = rgb_to_hsv(hi.color.r, hi.color.g, hi.color.b);
576                let mut dh = hb - ha;
577                if dh >  180.0 { dh -= 360.0; }
578                if dh < -180.0 { dh += 360.0; }
579                let h = ha + dh * f;
580                let s = sa + (sb - sa) * f;
581                let v = va + (vb - va) * f;
582                let (r, g, b) = hsv_to_rgb(h, s, v);
583                Rgba::rgb(r, g, b)
584            }
585        }
586    }
587
588    /// Produce a `Vec<Rgba>` LUT with `n` entries.
589    pub fn bake_lut(&self, n: usize) -> Vec<Rgba> {
590        (0..n).map(|i| self.sample(i as f32 / (n - 1) as f32)).collect()
591    }
592}
593
594// ── Named gradients ───────────────────────────────────────────────────────────
595
596// The scientific colour maps come from the `colorgrad` crate, which carries
597// the published control points (matplotlib's viridis family, ColorBrewer,
598// Google's turbo, cubehelix and more) and fits a smooth basis spline through
599// them. The hand-picked five or six stops these used to have drifted
600// visibly from the real maps between the stops.
601
602/// The `colorgrad` crate, re-exported so its gradients can be passed to
603/// [`Gradient::from_colorgrad`] without a version mismatch.
604pub use colorgrad;
605
606/// Every name [`preset_gradient`] accepts.
607pub const PRESET_GRADIENTS: &[&str] = &[
608    // perceptually uniform, sequential
609    "viridis", "inferno", "magma", "plasma", "cividis", "turbo",
610    // cyclic and rainbow
611    "sinebow", "rainbow", "cubehelix", "warm", "cool",
612    // ColorBrewer diverging
613    "spectral", "rd_bu", "rd_yl_bu", "rd_yl_gn", "br_bg", "pr_gn", "pi_yg", "pu_or", "rd_gy",
614    // ColorBrewer sequential
615    "blues", "greens", "greys", "oranges", "purples", "reds",
616    "bu_gn", "bu_pu", "gn_bu", "or_rd", "pu_bu_gn", "pu_bu", "pu_rd", "rd_pu",
617    "yl_gn_bu", "yl_gn", "yl_or_br", "yl_or_rd",
618];
619
620/// Stops baked from a preset. Enough that piecewise-linear sampling stays
621/// within one 8-bit step of the spline.
622const PRESET_STOPS: usize = 64;
623
624/// A named colour map, baked into a [`Gradient`] (see [`PRESET_GRADIENTS`]).
625///
626/// Names are matched case-insensitively, and `-` or spaces may stand in for
627/// `_`, so `"RdYlBu"`-style names work as `"rd-yl-bu"`.
628///
629/// ```rust
630/// use proof_engine::math::color::preset_gradient;
631/// let turbo = preset_gradient("turbo").unwrap();
632/// let lut = turbo.bake_lut(256); // ready to upload as a 1D texture
633/// assert_eq!(lut.len(), 256);
634/// assert!(preset_gradient("no such map").is_none());
635/// ```
636pub fn preset_gradient(name: &str) -> Option<Gradient> {
637    use colorgrad::preset as p;
638    let key = name.trim().to_ascii_lowercase().replace(['-', ' '], "_");
639    let n = PRESET_STOPS;
640    Some(match key.as_str() {
641        "viridis" => Gradient::from_colorgrad(&p::viridis(), n),
642        "inferno" => Gradient::from_colorgrad(&p::inferno(), n),
643        "magma" => Gradient::from_colorgrad(&p::magma(), n),
644        "plasma" => Gradient::from_colorgrad(&p::plasma(), n),
645        "cividis" => Gradient::from_colorgrad(&p::cividis(), n),
646        "turbo" => Gradient::from_colorgrad(&p::turbo(), n),
647        "sinebow" => Gradient::from_colorgrad(&p::sinebow(), n),
648        "rainbow" => Gradient::from_colorgrad(&p::rainbow(), n),
649        "cubehelix" => Gradient::from_colorgrad(&p::cubehelix_default(), n),
650        "warm" => Gradient::from_colorgrad(&p::warm(), n),
651        "cool" => Gradient::from_colorgrad(&p::cool(), n),
652        "spectral" => Gradient::from_colorgrad(&p::spectral(), n),
653        "rd_bu" => Gradient::from_colorgrad(&p::rd_bu(), n),
654        "rd_yl_bu" => Gradient::from_colorgrad(&p::rd_yl_bu(), n),
655        "rd_yl_gn" => Gradient::from_colorgrad(&p::rd_yl_gn(), n),
656        "br_bg" => Gradient::from_colorgrad(&p::br_bg(), n),
657        "pr_gn" => Gradient::from_colorgrad(&p::pr_gn(), n),
658        "pi_yg" => Gradient::from_colorgrad(&p::pi_yg(), n),
659        "pu_or" => Gradient::from_colorgrad(&p::pu_or(), n),
660        "rd_gy" => Gradient::from_colorgrad(&p::rd_gy(), n),
661        "blues" => Gradient::from_colorgrad(&p::blues(), n),
662        "greens" => Gradient::from_colorgrad(&p::greens(), n),
663        "greys" | "grays" => Gradient::from_colorgrad(&p::greys(), n),
664        "oranges" => Gradient::from_colorgrad(&p::oranges(), n),
665        "purples" => Gradient::from_colorgrad(&p::purples(), n),
666        "reds" => Gradient::from_colorgrad(&p::reds(), n),
667        "bu_gn" => Gradient::from_colorgrad(&p::bu_gn(), n),
668        "bu_pu" => Gradient::from_colorgrad(&p::bu_pu(), n),
669        "gn_bu" => Gradient::from_colorgrad(&p::gn_bu(), n),
670        "or_rd" => Gradient::from_colorgrad(&p::or_rd(), n),
671        "pu_bu_gn" => Gradient::from_colorgrad(&p::pu_bu_gn(), n),
672        "pu_bu" => Gradient::from_colorgrad(&p::pu_bu(), n),
673        "pu_rd" => Gradient::from_colorgrad(&p::pu_rd(), n),
674        "rd_pu" => Gradient::from_colorgrad(&p::rd_pu(), n),
675        "yl_gn_bu" => Gradient::from_colorgrad(&p::yl_gn_bu(), n),
676        "yl_gn" => Gradient::from_colorgrad(&p::yl_gn(), n),
677        "yl_or_br" => Gradient::from_colorgrad(&p::yl_or_br(), n),
678        "yl_or_rd" => Gradient::from_colorgrad(&p::yl_or_rd(), n),
679        _ => return None,
680    })
681}
682
683impl Gradient {
684    /// Bake any `colorgrad` gradient into `samples` evenly spaced stops,
685    /// interpolated in [`GradientMode::LinearRgb`] between them.
686    ///
687    /// Colours keep the values `colorgrad` gives, the same 0 to 1 encoding
688    /// as [`Rgba::from_hex`].
689    pub fn from_colorgrad<G: colorgrad::Gradient + ?Sized>(g: &G, samples: usize) -> Gradient {
690        let (lo, hi) = g.domain();
691        let n = samples.max(2);
692        let mut out = Gradient::new(GradientMode::LinearRgb);
693        out.stops = (0..n)
694            .map(|i| {
695                let t = i as f32 / (n - 1) as f32;
696                let c = g.at(lo + (hi - lo) * t);
697                // Basis splines can overshoot by a rounding error.
698                let c = c.clamp();
699                ColorStop { t, color: Rgba::new(c.r, c.g, c.b, c.a) }
700            })
701            .collect();
702        out
703    }
704
705    /// Parse a CSS-style gradient, as written inside `linear-gradient()`:
706    /// colour names, hex, `rgb()`, `hsl()` and so on, with optional
707    /// percentage positions.
708    ///
709    /// ```rust
710    /// use proof_engine::math::color::Gradient;
711    /// let g = Gradient::from_css("#000, deeppink 40%, gold").unwrap();
712    /// let mid = g.sample(0.4);
713    /// assert!(mid.r > 0.99 && mid.g < 0.1);
714    /// assert!(Gradient::from_css("not a colour, at all").is_err());
715    /// ```
716    pub fn from_css(css: &str) -> Result<Gradient, String> {
717        let g = colorgrad::GradientBuilder::new()
718            .css(css)
719            .build::<colorgrad::LinearGradient>()
720            .map_err(|e| format!("bad gradient {css:?}: {e}"))?;
721        // Linear in RGB between its stops, so 256 samples reproduce it to
722        // within a quarter of a percent of the width, hard stops included.
723        Ok(Gradient::from_colorgrad(&g, 256))
724    }
725}
726
727/// Matplotlib's plasma.
728pub fn gradient_plasma() -> Gradient {
729    preset_gradient("plasma").expect("built-in preset")
730}
731
732/// Matplotlib's inferno.
733pub fn gradient_inferno() -> Gradient {
734    preset_gradient("inferno").expect("built-in preset")
735}
736
737/// Matplotlib's viridis.
738pub fn gradient_viridis() -> Gradient {
739    preset_gradient("viridis").expect("built-in preset")
740}
741
742/// Black through dark red, orange and yellow to white.
743pub fn gradient_fire() -> Gradient {
744    Gradient::new(GradientMode::LinearRgb)
745        .add_stop(0.0, Rgba::BLACK)
746        .add_stop(0.3, Rgba::rgb(0.5, 0.0, 0.0))
747        .add_stop(0.6, Rgba::rgb(1.0, 0.3, 0.0))
748        .add_stop(0.8, Rgba::rgb(1.0, 0.8, 0.0))
749        .add_stop(1.0, Rgba::WHITE)
750}
751
752/// Black through deep blue and sky blue to white, blended in Oklab.
753pub fn gradient_ice() -> Gradient {
754    Gradient::new(GradientMode::Oklab)
755        .add_stop(0.0, Rgba::BLACK)
756        .add_stop(0.4, Rgba::rgb(0.0, 0.2, 0.5))
757        .add_stop(0.7, Rgba::rgb(0.2, 0.6, 1.0))
758        .add_stop(1.0, Rgba::WHITE)
759}
760
761/// Magenta to cyan and back to magenta, blended in Oklab.
762pub fn gradient_neon() -> Gradient {
763    Gradient::new(GradientMode::Oklab)
764        .add_stop(0.0, Rgba::from_hex(0xff00ff))
765        .add_stop(0.5, Rgba::from_hex(0x00ffff))
766        .add_stop(1.0, Rgba::from_hex(0xff00ff))
767}
768
769/// Health bar colours: red at 0, amber at 0.5, green at 1.
770pub fn gradient_health() -> Gradient {
771    Gradient::new(GradientMode::Oklab)
772        .add_stop(0.0, Rgba::rgb(1.0, 0.0, 0.0))
773        .add_stop(0.5, Rgba::rgb(1.0, 0.8, 0.0))
774        .add_stop(1.0, Rgba::rgb(0.0, 1.0, 0.2))
775}
776
777// ── Color harmonies ───────────────────────────────────────────────────────────
778
779/// Generate a complementary color (180° hue rotation).
780pub fn complementary(c: Rgba) -> Rgba {
781    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
782    let (r, g, b) = hsv_to_rgb((h + 180.0) % 360.0, s, v);
783    Rgba::rgb(r, g, b)
784}
785
786/// Generate split-complementary colors (150° and 210°).
787pub fn split_complementary(c: Rgba) -> (Rgba, Rgba) {
788    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
789    let mk = |dh: f32| {
790        let (r, g, b) = hsv_to_rgb((h + dh) % 360.0, s, v);
791        Rgba::rgb(r, g, b)
792    };
793    (mk(150.0), mk(210.0))
794}
795
796/// Generate triadic colors (120° apart).
797pub fn triadic(c: Rgba) -> (Rgba, Rgba) {
798    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
799    let mk = |dh: f32| {
800        let (r, g, b) = hsv_to_rgb((h + dh) % 360.0, s, v);
801        Rgba::rgb(r, g, b)
802    };
803    (mk(120.0), mk(240.0))
804}
805
806/// Generate analogous colors (±30°).
807pub fn analogous(c: Rgba) -> (Rgba, Rgba) {
808    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
809    let mk = |dh: f32| {
810        let (r, g, b) = hsv_to_rgb((h + dh + 360.0) % 360.0, s, v);
811        Rgba::rgb(r, g, b)
812    };
813    (mk(-30.0), mk(30.0))
814}
815
816/// Generate a tetradic (square) color scheme.
817pub fn tetradic(c: Rgba) -> [Rgba; 4] {
818    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
819    std::array::from_fn(|i| {
820        let (r, g, b) = hsv_to_rgb((h + i as f32 * 90.0) % 360.0, s, v);
821        Rgba::rgb(r, g, b)
822    })
823}
824
825// ── Palette types ─────────────────────────────────────────────────────────────
826
827/// A named palette of colors.
828#[derive(Debug, Clone)]
829pub struct Palette {
830    /// Display name.
831    pub name:   String,
832    /// The colours, in order.
833    pub colors: Vec<Rgba>,
834}
835
836impl Palette {
837    /// A palette called `name` with these colours.
838    pub fn new(name: impl Into<String>, colors: Vec<Rgba>) -> Self {
839        Self { name: name.into(), colors }
840    }
841
842    /// Sample the palette by index (wraps around).
843    pub fn get(&self, i: usize) -> Rgba {
844        if self.colors.is_empty() { return Rgba::WHITE; }
845        self.colors[i % self.colors.len()]
846    }
847
848    /// Sample interpolated between palette colors.
849    pub fn sample(&self, t: f32) -> Rgba {
850        if self.colors.is_empty() { return Rgba::WHITE; }
851        if self.colors.len() == 1 { return self.colors[0]; }
852        let t = t.fract().abs();
853        let f = t * (self.colors.len() - 1) as f32;
854        let i = f as usize;
855        let j = (i + 1).min(self.colors.len() - 1);
856        self.colors[i].lerp(self.colors[j], f.fract())
857    }
858}
859
860/// CRT terminal / retrowave palette.
861pub fn palette_crt() -> Palette {
862    Palette::new("CRT", vec![
863        Rgba::from_hex(0x00ff00), // phosphor green
864        Rgba::from_hex(0x00ffff), // cyan
865        Rgba::from_hex(0xff6600), // amber
866        Rgba::from_hex(0xffffff), // white
867    ])
868}
869
870/// ANSI 16-color terminal palette.
871pub fn palette_ansi16() -> Palette {
872    Palette::new("ANSI16", vec![
873        Rgba::from_hex(0x000000), Rgba::from_hex(0xaa0000),
874        Rgba::from_hex(0x00aa00), Rgba::from_hex(0xaa5500),
875        Rgba::from_hex(0x0000aa), Rgba::from_hex(0xaa00aa),
876        Rgba::from_hex(0x00aaaa), Rgba::from_hex(0xaaaaaa),
877        Rgba::from_hex(0x555555), Rgba::from_hex(0xff5555),
878        Rgba::from_hex(0x55ff55), Rgba::from_hex(0xffff55),
879        Rgba::from_hex(0x5555ff), Rgba::from_hex(0xff55ff),
880        Rgba::from_hex(0x55ffff), Rgba::from_hex(0xffffff),
881    ])
882}
883
884/// Chaos RPG element colors.
885pub fn palette_chaos_elements() -> Palette {
886    Palette::new("ChaosElements", vec![
887        Rgba::from_hex(0xff4400), // fire
888        Rgba::from_hex(0x00aaff), // water/ice
889        Rgba::from_hex(0x44ff44), // life
890        Rgba::from_hex(0xaa00ff), // shadow/void
891        Rgba::from_hex(0xffcc00), // lightning
892        Rgba::from_hex(0x22ffcc), // arcane
893        Rgba::from_hex(0xff00aa), // chaos
894    ])
895}
896
897// ── Tone mapping ──────────────────────────────────────────────────────────────
898
899/// Reinhard tone mapping operator.
900pub fn tonemap_reinhard(c: Rgba) -> Rgba {
901    let map = |x: f32| x / (x + 1.0);
902    Rgba::new(map(c.r), map(c.g), map(c.b), c.a)
903}
904
905/// ACES filmic tone mapping approximation (Narkowicz 2015).
906pub fn tonemap_aces(c: Rgba) -> Rgba {
907    let aces = |x: f32| -> f32 {
908        const A: f32 = 2.51;
909        const B: f32 = 0.03;
910        const C: f32 = 2.43;
911        const D: f32 = 0.59;
912        const E: f32 = 0.14;
913        ((x * (A * x + B)) / (x * (C * x + D) + E)).clamp(0.0, 1.0)
914    };
915    Rgba::new(aces(c.r), aces(c.g), aces(c.b), c.a)
916}
917
918/// Uncharted 2 "Hable" filmic tone mapping.
919pub fn tonemap_uncharted2(c: Rgba) -> Rgba {
920    fn partial(x: f32) -> f32 {
921        const A: f32 = 0.15; const B: f32 = 0.50;
922        const C: f32 = 0.10; const D: f32 = 0.20;
923        const E: f32 = 0.02; const F: f32 = 0.30;
924        ((x*(A*x+C*B)+D*E) / (x*(A*x+B)+D*F)) - E/F
925    }
926    let exposure_bias = 2.0_f32;
927    let curr = |x: f32| partial(x * exposure_bias);
928    let white = partial(11.2);
929    let scale = 1.0 / white;
930    Rgba::new(curr(c.r)*scale, curr(c.g)*scale, curr(c.b)*scale, c.a)
931}
932
933// ── Color distance ────────────────────────────────────────────────────────────
934
935/// Euclidean distance in linear RGB space.
936pub fn distance_rgb(a: Rgba, b: Rgba) -> f32 {
937    let dr = a.r - b.r; let dg = a.g - b.g; let db = a.b - b.b;
938    (dr*dr + dg*dg + db*db).sqrt()
939}
940
941/// Perceptual distance using CIE Lab Delta-E 1976.
942pub fn distance_lab_e76(a: Rgba, b: Rgba) -> f32 {
943    Lab::from_rgb(a).delta_e(&Lab::from_rgb(b))
944}
945
946/// Find the nearest color in a palette (by Lab Delta-E).
947pub fn nearest_in_palette(color: Rgba, palette: &Palette) -> usize {
948    let lab = Lab::from_rgb(color);
949    palette.colors.iter()
950        .enumerate()
951        .min_by(|(_, &a), (_, &b)| {
952            let da = lab.delta_e(&Lab::from_rgb(a));
953            let db = lab.delta_e(&Lab::from_rgb(b));
954            da.partial_cmp(&db).unwrap()
955        })
956        .map(|(i, _)| i)
957        .unwrap_or(0)
958}
959
960// ── Color adjustment ──────────────────────────────────────────────────────────
961
962/// Adjust hue by `delta` degrees.
963pub fn adjust_hue(c: Rgba, delta: f32) -> Rgba {
964    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
965    let (r, g, b) = hsv_to_rgb((h + delta + 360.0) % 360.0, s, v);
966    Rgba::new(r, g, b, c.a)
967}
968
969/// Saturate or desaturate (1 = no change, 0 = greyscale, >1 = boost).
970pub fn adjust_saturation(c: Rgba, factor: f32) -> Rgba {
971    let lum = c.luminance();
972    Rgba::new(
973        lum + (c.r - lum) * factor,
974        lum + (c.g - lum) * factor,
975        lum + (c.b - lum) * factor,
976        c.a,
977    )
978}
979
980/// Adjust brightness (additive offset).
981pub fn adjust_brightness(c: Rgba, delta: f32) -> Rgba {
982    Rgba::new((c.r + delta).clamp(0.0, 1.0),
983              (c.g + delta).clamp(0.0, 1.0),
984              (c.b + delta).clamp(0.0, 1.0),
985              c.a)
986}
987
988/// Adjust contrast around 0.5 midpoint (factor >1 = more contrast).
989pub fn adjust_contrast(c: Rgba, factor: f32) -> Rgba {
990    let adj = |x: f32| ((x - 0.5) * factor + 0.5).clamp(0.0, 1.0);
991    Rgba::new(adj(c.r), adj(c.g), adj(c.b), c.a)
992}
993
994/// Mix color `c` with white by `factor ∈ [0, 1]` (0 = original, 1 = white).
995pub fn tint_white(c: Rgba, factor: f32) -> Rgba {
996    c.lerp(Rgba::WHITE, factor)
997}
998
999/// Mix color `c` with black by `factor ∈ [0, 1]` (0 = original, 1 = black).
1000pub fn shade_black(c: Rgba, factor: f32) -> Rgba {
1001    c.lerp(Rgba::BLACK, factor)
1002}
1003
1004// ── Tests ─────────────────────────────────────────────────────────────────────
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009
1010    fn close(a: Rgba, hex: u32) -> bool {
1011        let b = Rgba::from_hex(hex);
1012        (a.r - b.r).abs() < 0.02 && (a.g - b.g).abs() < 0.02 && (a.b - b.b).abs() < 0.02
1013    }
1014
1015    #[test]
1016    fn presets_hit_the_published_end_points() {
1017        // viridis runs from #440154 to #fde725, plasma from #0d0887 to
1018        // #f0f921, inferno from #000004 to #fcffa4.
1019        let v = gradient_viridis();
1020        assert!(close(v.sample(0.0), 0x440154) && close(v.sample(1.0), 0xfde725));
1021        let p = gradient_plasma();
1022        assert!(close(p.sample(0.0), 0x0d0887) && close(p.sample(1.0), 0xf0f921));
1023        let i = gradient_inferno();
1024        assert!(close(i.sample(0.0), 0x000004) && close(i.sample(1.0), 0xfcffa4));
1025        // viridis's published midpoint is the teal #21918c. The old
1026        // five-stop version gave #35b779 there, 0.15 off in green; the
1027        // colorgrad fit is within 0.06 on every channel.
1028        let (m, want) = (v.sample(0.5), Rgba::from_hex(0x21918c));
1029        let err = (m.r - want.r).abs().max((m.g - want.g).abs()).max((m.b - want.b).abs());
1030        assert!(err < 0.06, "{m:?} is {err} from #21918c");
1031    }
1032
1033    #[test]
1034    fn every_listed_preset_resolves_and_is_well_formed() {
1035        for name in PRESET_GRADIENTS {
1036            let g = preset_gradient(name).unwrap_or_else(|| panic!("{name} missing"));
1037            assert_eq!(g.stops.len(), PRESET_STOPS);
1038            for c in g.bake_lut(17) {
1039                for ch in [c.r, c.g, c.b, c.a] {
1040                    assert!((0.0..=1.0).contains(&ch), "{name}: {ch}");
1041                }
1042            }
1043        }
1044        assert!(preset_gradient("Rd-Yl-Bu").is_some());
1045        assert!(preset_gradient("nope").is_none());
1046    }
1047
1048    #[test]
1049    fn css_gradients_parse_with_positions_and_hard_stops() {
1050        let g = Gradient::from_css("red, red 50%, blue 50%, blue").unwrap();
1051        assert!(close(g.sample(0.25), 0xff0000));
1052        assert!(close(g.sample(0.75), 0x0000ff));
1053        let g = Gradient::from_css("#000, #fff").unwrap();
1054        assert!((g.sample(0.5).r - 0.5).abs() < 0.01);
1055        assert!(Gradient::from_css("").is_err() || Gradient::from_css("").unwrap().stops.len() >= 2);
1056        assert!(Gradient::from_css("bogus, nonsense").is_err());
1057    }
1058
1059    fn approx_eq(a: f32, b: f32) -> bool { (a - b).abs() < 0.005 }
1060
1061    #[test]
1062    fn hsv_roundtrip() {
1063        let (h0, s0, v0) = (200.0f32, 0.7, 0.8);
1064        let (r, g, b) = hsv_to_rgb(h0, s0, v0);
1065        let (h1, s1, v1) = rgb_to_hsv(r, g, b);
1066        assert!(approx_eq(h0, h1), "hue mismatch: {h0} vs {h1}");
1067        assert!(approx_eq(s0, s1));
1068        assert!(approx_eq(v0, v1));
1069    }
1070
1071    #[test]
1072    fn hsl_roundtrip() {
1073        let (r, g, b) = hsl_to_rgb(120.0, 0.5, 0.5);
1074        let (h, s, l) = rgb_to_hsl(r, g, b);
1075        assert!(approx_eq(h, 120.0), "hue mismatch: {h}");
1076        assert!(approx_eq(s, 0.5));
1077        assert!(approx_eq(l, 0.5));
1078    }
1079
1080    #[test]
1081    fn oklab_roundtrip() {
1082        let c = Rgba::from_hex(0x3a7bd5);
1083        let oklab = Oklab::from_linear_rgb(c);
1084        let back  = oklab.to_linear_rgb();
1085        assert!(approx_eq(c.r, back.r), "r mismatch: {} vs {}", c.r, back.r);
1086        assert!(approx_eq(c.g, back.g));
1087        assert!(approx_eq(c.b, back.b));
1088    }
1089
1090    #[test]
1091    fn gradient_endpoints() {
1092        let g = gradient_fire();
1093        let lo = g.sample(0.0);
1094        let hi = g.sample(1.0);
1095        assert!(lo.luminance() < 0.01);
1096        assert!(hi.luminance() > 0.9);
1097    }
1098
1099    #[test]
1100    fn complementary_is_180_degrees() {
1101        let c = Rgba::from_hex(0xff0000); // red
1102        let comp = complementary(c);
1103        let (h, _, _) = rgb_to_hsv(comp.r, comp.g, comp.b);
1104        // Complementary of red (0°) should be cyan (180°)
1105        assert!((h - 180.0).abs() < 2.0, "hue={h}");
1106    }
1107
1108    #[test]
1109    fn tonemap_aces_bounds() {
1110        let bright = Rgba::rgb(10.0, 5.0, 2.0); // HDR value
1111        let tm = tonemap_aces(bright);
1112        assert!(tm.r <= 1.0);
1113        assert!(tm.g <= 1.0);
1114        assert!(tm.b <= 1.0);
1115    }
1116}