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
7use glam::{Vec3, Vec4};
8use std::f32::consts::PI;
9
10// ── Core color types ──────────────────────────────────────────────────────────
11
12/// Linear RGB color with alpha, all in `[0.0, 1.0]`.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct Rgba {
15    pub r: f32,
16    pub g: f32,
17    pub b: f32,
18    pub a: f32,
19}
20
21impl Rgba {
22    pub const WHITE:   Rgba = Rgba { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
23    pub const BLACK:   Rgba = Rgba { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
24    pub const RED:     Rgba = Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
25    pub const GREEN:   Rgba = Rgba { r: 0.0, g: 1.0, b: 0.0, a: 1.0 };
26    pub const BLUE:    Rgba = Rgba { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
27    pub const YELLOW:  Rgba = Rgba { r: 1.0, g: 1.0, b: 0.0, a: 1.0 };
28    pub const CYAN:    Rgba = Rgba { r: 0.0, g: 1.0, b: 1.0, a: 1.0 };
29    pub const MAGENTA: Rgba = Rgba { r: 1.0, g: 0.0, b: 1.0, a: 1.0 };
30    pub const TRANSPARENT: Rgba = Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
31
32    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self { Self { r, g, b, a } }
33    pub fn rgb(r: f32, g: f32, b: f32) -> Self { Self { r, g, b, a: 1.0 } }
34
35    pub fn from_vec4(v: Vec4) -> Self { Self { r: v.x, g: v.y, b: v.z, a: v.w } }
36    pub fn to_vec4(self) -> Vec4 { Vec4::new(self.r, self.g, self.b, self.a) }
37    pub fn to_vec3(self) -> Vec3 { Vec3::new(self.r, self.g, self.b) }
38
39    /// Construct from an `0xRRGGBB` hex literal (alpha = 1).
40    pub fn from_hex(hex: u32) -> Self {
41        let r = ((hex >> 16) & 0xFF) as f32 / 255.0;
42        let g = ((hex >> 8)  & 0xFF) as f32 / 255.0;
43        let b = ( hex        & 0xFF) as f32 / 255.0;
44        Self::rgb(r, g, b)
45    }
46
47    /// Construct from an `0xRRGGBBAA` hex literal.
48    pub fn from_hex_alpha(hex: u32) -> Self {
49        let r = ((hex >> 24) & 0xFF) as f32 / 255.0;
50        let g = ((hex >> 16) & 0xFF) as f32 / 255.0;
51        let b = ((hex >> 8)  & 0xFF) as f32 / 255.0;
52        let a = ( hex        & 0xFF) as f32 / 255.0;
53        Self { r, g, b, a }
54    }
55
56    pub fn with_alpha(self, a: f32) -> Self { Self { a, ..self } }
57    pub fn lerp(self, other: Rgba, t: f32) -> Self {
58        Rgba {
59            r: self.r + (other.r - self.r) * t,
60            g: self.g + (other.g - self.g) * t,
61            b: self.b + (other.b - self.b) * t,
62            a: self.a + (other.a - self.a) * t,
63        }
64    }
65
66    /// Premultiplied alpha blend: self over other.
67    pub fn over(self, other: Rgba) -> Rgba {
68        let ia = 1.0 - self.a;
69        Rgba {
70            r: self.r * self.a + other.r * ia,
71            g: self.g * self.a + other.g * ia,
72            b: self.b * self.a + other.b * ia,
73            a: self.a + other.a * ia,
74        }
75    }
76
77    /// Linear luminance (ITU-R BT.709).
78    pub fn luminance(self) -> f32 {
79        0.2126 * self.r + 0.7152 * self.g + 0.0722 * self.b
80    }
81
82    /// Convert to 8-bit RGBA tuple.
83    pub fn to_u8(self) -> [u8; 4] {
84        [
85            (self.r.clamp(0.0, 1.0) * 255.0) as u8,
86            (self.g.clamp(0.0, 1.0) * 255.0) as u8,
87            (self.b.clamp(0.0, 1.0) * 255.0) as u8,
88            (self.a.clamp(0.0, 1.0) * 255.0) as u8,
89        ]
90    }
91}
92
93impl From<Vec4> for Rgba {
94    fn from(v: Vec4) -> Self { Self::from_vec4(v) }
95}
96
97impl From<Rgba> for Vec4 {
98    fn from(c: Rgba) -> Self { c.to_vec4() }
99}
100
101// ── sRGB gamma ────────────────────────────────────────────────────────────────
102
103/// Apply sRGB gamma (linear → display).
104#[inline]
105pub fn linear_to_srgb_channel(x: f32) -> f32 {
106    if x <= 0.003_130_8 {
107        x * 12.92
108    } else {
109        1.055 * x.powf(1.0 / 2.4) - 0.055
110    }
111}
112
113/// Remove sRGB gamma (display → linear).
114#[inline]
115pub fn srgb_to_linear_channel(x: f32) -> f32 {
116    if x <= 0.040_45 {
117        x / 12.92
118    } else {
119        ((x + 0.055) / 1.055).powf(2.4)
120    }
121}
122
123pub fn linear_to_srgb(c: Rgba) -> Rgba {
124    Rgba::new(
125        linear_to_srgb_channel(c.r),
126        linear_to_srgb_channel(c.g),
127        linear_to_srgb_channel(c.b),
128        c.a,
129    )
130}
131
132pub fn srgb_to_linear(c: Rgba) -> Rgba {
133    Rgba::new(
134        srgb_to_linear_channel(c.r),
135        srgb_to_linear_channel(c.g),
136        srgb_to_linear_channel(c.b),
137        c.a,
138    )
139}
140
141// ── HSV ───────────────────────────────────────────────────────────────────────
142
143/// HSV color: hue in `[0, 360)`, saturation and value in `[0, 1]`.
144#[derive(Debug, Clone, Copy)]
145pub struct Hsv { pub h: f32, pub s: f32, pub v: f32 }
146
147impl Hsv {
148    pub fn new(h: f32, s: f32, v: f32) -> Self { Self { h, s, v } }
149
150    pub fn to_rgb(self) -> Rgba {
151        let (r, g, b) = hsv_to_rgb(self.h, self.s, self.v);
152        Rgba::rgb(r, g, b)
153    }
154
155    pub fn from_rgb(c: Rgba) -> Self {
156        let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
157        Self { h, s, v }
158    }
159}
160
161pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
162    if s == 0.0 { return (v, v, v); }
163    let h = ((h % 360.0) + 360.0) % 360.0;
164    let i = (h / 60.0) as u32;
165    let f = h / 60.0 - i as f32;
166    let p = v * (1.0 - s);
167    let q = v * (1.0 - s * f);
168    let t = v * (1.0 - s * (1.0 - f));
169    match i {
170        0 => (v, t, p),
171        1 => (q, v, p),
172        2 => (p, v, t),
173        3 => (p, q, v),
174        4 => (t, p, v),
175        _ => (v, p, q),
176    }
177}
178
179pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
180    let max = r.max(g).max(b);
181    let min = r.min(g).min(b);
182    let delta = max - min;
183
184    let v = max;
185    let s = if max < 1e-8 { 0.0 } else { delta / max };
186    let h = if delta < 1e-8 {
187        0.0
188    } else if max == r {
189        60.0 * (((g - b) / delta) % 6.0)
190    } else if max == g {
191        60.0 * ((b - r) / delta + 2.0)
192    } else {
193        60.0 * ((r - g) / delta + 4.0)
194    };
195    (((h % 360.0) + 360.0) % 360.0, s, v)
196}
197
198// ── HSL ───────────────────────────────────────────────────────────────────────
199
200/// HSL color: hue in `[0, 360)`, saturation and lightness in `[0, 1]`.
201#[derive(Debug, Clone, Copy)]
202pub struct Hsl { pub h: f32, pub s: f32, pub l: f32 }
203
204impl Hsl {
205    pub fn new(h: f32, s: f32, l: f32) -> Self { Self { h, s, l } }
206
207    pub fn to_rgb(self) -> Rgba {
208        let (r, g, b) = hsl_to_rgb(self.h, self.s, self.l);
209        Rgba::rgb(r, g, b)
210    }
211}
212
213fn hue_to_rgb(p: f32, q: f32, t: f32) -> f32 {
214    let t = ((t % 1.0) + 1.0) % 1.0;
215    if t < 1.0 / 6.0 { return p + (q - p) * 6.0 * t; }
216    if t < 1.0 / 2.0 { return q; }
217    if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }
218    p
219}
220
221pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
222    if s == 0.0 { return (l, l, l); }
223    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
224    let p = 2.0 * l - q;
225    let h = h / 360.0;
226    (
227        hue_to_rgb(p, q, h + 1.0 / 3.0),
228        hue_to_rgb(p, q, h),
229        hue_to_rgb(p, q, h - 1.0 / 3.0),
230    )
231}
232
233pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
234    let max = r.max(g).max(b);
235    let min = r.min(g).min(b);
236    let l   = (max + min) * 0.5;
237    let delta = max - min;
238
239    if delta < 1e-8 { return (0.0, 0.0, l); }
240
241    let s = if l < 0.5 { delta / (max + min) } else { delta / (2.0 - max - min) };
242    let h = if max == r {
243        60.0 * ((g - b) / delta + if g < b { 6.0 } else { 0.0 })
244    } else if max == g {
245        60.0 * ((b - r) / delta + 2.0)
246    } else {
247        60.0 * ((r - g) / delta + 4.0)
248    };
249    (h, s, l)
250}
251
252// ── Oklab ────────────────────────────────────────────────────────────────────
253
254/// Oklab color: a perceptually uniform color space by Björn Ottosson.
255/// `L` = lightness [0,1], `a` and `b` are chroma axes (approx −0.5..0.5).
256#[derive(Debug, Clone, Copy)]
257pub struct Oklab { pub l: f32, pub a: f32, pub b: f32 }
258
259impl Oklab {
260    pub fn from_linear_rgb(c: Rgba) -> Self {
261        let l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
262        let m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
263        let s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
264
265        let l_ = l.cbrt();
266        let m_ = m.cbrt();
267        let s_ = s.cbrt();
268
269        Self {
270            l: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
271            a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
272            b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
273        }
274    }
275
276    pub fn to_linear_rgb(self) -> Rgba {
277        let l_ = self.l + 0.3963377774 * self.a + 0.2158037573 * self.b;
278        let m_ = self.l - 0.1055613458 * self.a - 0.0638541728 * self.b;
279        let s_ = self.l - 0.0894841775 * self.a - 1.2914855480 * self.b;
280
281        let l = l_ * l_ * l_;
282        let m = m_ * m_ * m_;
283        let s = s_ * s_ * s_;
284
285        Rgba::rgb(
286             4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
287            -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
288            -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
289        )
290    }
291
292    /// Perceptually-uniform lerp in Oklab space.
293    pub fn lerp(self, other: Oklab, t: f32) -> Oklab {
294        Oklab {
295            l: self.l + (other.l - self.l) * t,
296            a: self.a + (other.a - self.a) * t,
297            b: self.b + (other.b - self.b) * t,
298        }
299    }
300}
301
302// ── CIE XYZ ───────────────────────────────────────────────────────────────────
303
304/// CIE XYZ (D65 white point).
305#[derive(Debug, Clone, Copy)]
306pub struct Xyz { pub x: f32, pub y: f32, pub z: f32 }
307
308impl Xyz {
309    pub fn from_linear_rgb(c: Rgba) -> Self {
310        Self {
311            x: c.r * 0.4124 + c.g * 0.3576 + c.b * 0.1805,
312            y: c.r * 0.2126 + c.g * 0.7152 + c.b * 0.0722,
313            z: c.r * 0.0193 + c.g * 0.1192 + c.b * 0.9505,
314        }
315    }
316
317    pub fn to_linear_rgb(self) -> Rgba {
318        Rgba::rgb(
319             self.x *  3.2406 + self.y * -1.5372 + self.z * -0.4986,
320             self.x * -0.9689 + self.y *  1.8758 + self.z *  0.0415,
321             self.x *  0.0557 + self.y * -0.2040 + self.z *  1.0570,
322        )
323    }
324}
325
326// ── CIE Lab ───────────────────────────────────────────────────────────────────
327
328/// CIE L*a*b* color space (D65 white point).
329#[derive(Debug, Clone, Copy)]
330pub struct Lab { pub l: f32, pub a: f32, pub b: f32 }
331
332const D65_X: f32 = 0.95047;
333const D65_Y: f32 = 1.00000;
334const D65_Z: f32 = 1.08883;
335
336fn xyz_to_lab_f(t: f32) -> f32 {
337    if t > 0.008856 { t.cbrt() } else { 7.787 * t + 16.0 / 116.0 }
338}
339
340impl Lab {
341    pub fn from_xyz(xyz: Xyz) -> Self {
342        let fx = xyz_to_lab_f(xyz.x / D65_X);
343        let fy = xyz_to_lab_f(xyz.y / D65_Y);
344        let fz = xyz_to_lab_f(xyz.z / D65_Z);
345        Self {
346            l: 116.0 * fy - 16.0,
347            a: 500.0 * (fx - fy),
348            b: 200.0 * (fy - fz),
349        }
350    }
351
352    pub fn to_xyz(self) -> Xyz {
353        let fy = (self.l + 16.0) / 116.0;
354        let fx = self.a / 500.0 + fy;
355        let fz = fy - self.b / 200.0;
356        let cube = |v: f32| if v > 0.2069 { v * v * v } else { (v - 16.0 / 116.0) / 7.787 };
357        Xyz { x: cube(fx) * D65_X, y: cube(fy) * D65_Y, z: cube(fz) * D65_Z }
358    }
359
360    pub fn from_rgb(c: Rgba) -> Self {
361        Self::from_xyz(Xyz::from_linear_rgb(c))
362    }
363
364    pub fn to_rgb(self) -> Rgba {
365        self.to_xyz().to_linear_rgb()
366    }
367
368    /// Delta E 1976 (perceptual distance).
369    pub fn delta_e(&self, other: &Lab) -> f32 {
370        let dl = self.l - other.l;
371        let da = self.a - other.a;
372        let db = self.b - other.b;
373        (dl * dl + da * da + db * db).sqrt()
374    }
375}
376
377/// CIE LCH (Lightness, Chroma, Hue in degrees).
378#[derive(Debug, Clone, Copy)]
379pub struct Lch { pub l: f32, pub c: f32, pub h: f32 }
380
381impl Lch {
382    pub fn from_lab(lab: Lab) -> Self {
383        let c = (lab.a * lab.a + lab.b * lab.b).sqrt();
384        let h = lab.b.atan2(lab.a).to_degrees();
385        let h = ((h % 360.0) + 360.0) % 360.0;
386        Self { l: lab.l, c, h }
387    }
388
389    pub fn to_lab(self) -> Lab {
390        let h_rad = self.h.to_radians();
391        Lab { l: self.l, a: self.c * h_rad.cos(), b: self.c * h_rad.sin() }
392    }
393
394    pub fn from_rgb(c: Rgba) -> Self { Self::from_lab(Lab::from_rgb(c)) }
395    pub fn to_rgb(self) -> Rgba { self.to_lab().to_rgb() }
396
397    pub fn lerp_hue(self, other: Lch, t: f32) -> Lch {
398        // Shortest path around the hue circle
399        let mut dh = other.h - self.h;
400        if dh >  180.0 { dh -= 360.0; }
401        if dh < -180.0 { dh += 360.0; }
402        Lch {
403            l: self.l + (other.l - self.l) * t,
404            c: self.c + (other.c - self.c) * t,
405            h: self.h + dh * t,
406        }
407    }
408}
409
410// ── Gradient ──────────────────────────────────────────────────────────────────
411
412/// Interpolation mode for gradient stops.
413#[derive(Debug, Clone, Copy, PartialEq)]
414pub enum GradientMode {
415    /// Linear RGB interpolation.
416    LinearRgb,
417    /// Oklab interpolation (perceptually uniform, no "dark middle" artifacts).
418    Oklab,
419    /// LCH interpolation (preserves hue).
420    Lch,
421    /// HSV interpolation.
422    Hsv,
423}
424
425/// A color stop in a gradient.
426#[derive(Debug, Clone, Copy)]
427pub struct ColorStop {
428    pub t:     f32,   // [0, 1]
429    pub color: Rgba,
430}
431
432/// A multi-stop color gradient.
433#[derive(Debug, Clone)]
434pub struct Gradient {
435    pub stops: Vec<ColorStop>,
436    pub mode:  GradientMode,
437}
438
439impl Gradient {
440    pub fn new(mode: GradientMode) -> Self {
441        Self { stops: Vec::new(), mode }
442    }
443
444    pub fn add_stop(mut self, t: f32, color: Rgba) -> Self {
445        self.stops.push(ColorStop { t: t.clamp(0.0, 1.0), color });
446        self.stops.sort_by(|a, b| a.t.partial_cmp(&b.t).unwrap());
447        self
448    }
449
450    /// Sample the gradient at `t ∈ [0, 1]`.
451    pub fn sample(&self, t: f32) -> Rgba {
452        if self.stops.is_empty() { return Rgba::BLACK; }
453        if self.stops.len() == 1 { return self.stops[0].color; }
454
455        let t = t.clamp(0.0, 1.0);
456
457        // Find surrounding stops
458        let i = self.stops.partition_point(|s| s.t <= t);
459        if i == 0               { return self.stops[0].color; }
460        if i >= self.stops.len() { return self.stops.last().unwrap().color; }
461
462        let lo = &self.stops[i - 1];
463        let hi = &self.stops[i];
464        let f  = (t - lo.t) / (hi.t - lo.t).max(1e-8);
465
466        match self.mode {
467            GradientMode::LinearRgb => lo.color.lerp(hi.color, f),
468            GradientMode::Oklab => {
469                let a = Oklab::from_linear_rgb(lo.color);
470                let b = Oklab::from_linear_rgb(hi.color);
471                a.lerp(b, f).to_linear_rgb()
472            }
473            GradientMode::Lch => {
474                let a = Lch::from_rgb(lo.color);
475                let b = Lch::from_rgb(hi.color);
476                a.lerp_hue(b, f).to_rgb()
477            }
478            GradientMode::Hsv => {
479                let (ha, sa, va) = rgb_to_hsv(lo.color.r, lo.color.g, lo.color.b);
480                let (hb, sb, vb) = rgb_to_hsv(hi.color.r, hi.color.g, hi.color.b);
481                let mut dh = hb - ha;
482                if dh >  180.0 { dh -= 360.0; }
483                if dh < -180.0 { dh += 360.0; }
484                let h = ha + dh * f;
485                let s = sa + (sb - sa) * f;
486                let v = va + (vb - va) * f;
487                let (r, g, b) = hsv_to_rgb(h, s, v);
488                Rgba::rgb(r, g, b)
489            }
490        }
491    }
492
493    /// Produce a `Vec<Rgba>` LUT with `n` entries.
494    pub fn bake_lut(&self, n: usize) -> Vec<Rgba> {
495        (0..n).map(|i| self.sample(i as f32 / (n - 1) as f32)).collect()
496    }
497}
498
499// ── Named gradients ───────────────────────────────────────────────────────────
500
501pub fn gradient_plasma() -> Gradient {
502    Gradient::new(GradientMode::Oklab)
503        .add_stop(0.0, Rgba::from_hex(0x0d0887))
504        .add_stop(0.2, Rgba::from_hex(0x6a00a8))
505        .add_stop(0.4, Rgba::from_hex(0xb12a90))
506        .add_stop(0.6, Rgba::from_hex(0xe16462))
507        .add_stop(0.8, Rgba::from_hex(0xfca636))
508        .add_stop(1.0, Rgba::from_hex(0xf0f921))
509}
510
511pub fn gradient_inferno() -> Gradient {
512    Gradient::new(GradientMode::Oklab)
513        .add_stop(0.0, Rgba::from_hex(0x000004))
514        .add_stop(0.25, Rgba::from_hex(0x420a68))
515        .add_stop(0.5,  Rgba::from_hex(0x932667))
516        .add_stop(0.75, Rgba::from_hex(0xdd513a))
517        .add_stop(0.9,  Rgba::from_hex(0xfca50a))
518        .add_stop(1.0,  Rgba::from_hex(0xfcffa4))
519}
520
521pub fn gradient_viridis() -> Gradient {
522    Gradient::new(GradientMode::Oklab)
523        .add_stop(0.0,  Rgba::from_hex(0x440154))
524        .add_stop(0.25, Rgba::from_hex(0x31688e))
525        .add_stop(0.5,  Rgba::from_hex(0x35b779))
526        .add_stop(0.75, Rgba::from_hex(0x90d743))
527        .add_stop(1.0,  Rgba::from_hex(0xfde725))
528}
529
530pub fn gradient_fire() -> Gradient {
531    Gradient::new(GradientMode::LinearRgb)
532        .add_stop(0.0, Rgba::BLACK)
533        .add_stop(0.3, Rgba::rgb(0.5, 0.0, 0.0))
534        .add_stop(0.6, Rgba::rgb(1.0, 0.3, 0.0))
535        .add_stop(0.8, Rgba::rgb(1.0, 0.8, 0.0))
536        .add_stop(1.0, Rgba::WHITE)
537}
538
539pub fn gradient_ice() -> Gradient {
540    Gradient::new(GradientMode::Oklab)
541        .add_stop(0.0, Rgba::BLACK)
542        .add_stop(0.4, Rgba::rgb(0.0, 0.2, 0.5))
543        .add_stop(0.7, Rgba::rgb(0.2, 0.6, 1.0))
544        .add_stop(1.0, Rgba::WHITE)
545}
546
547pub fn gradient_neon() -> Gradient {
548    Gradient::new(GradientMode::Oklab)
549        .add_stop(0.0, Rgba::from_hex(0xff00ff))
550        .add_stop(0.5, Rgba::from_hex(0x00ffff))
551        .add_stop(1.0, Rgba::from_hex(0xff00ff))
552}
553
554pub fn gradient_health() -> Gradient {
555    Gradient::new(GradientMode::Oklab)
556        .add_stop(0.0, Rgba::rgb(1.0, 0.0, 0.0))
557        .add_stop(0.5, Rgba::rgb(1.0, 0.8, 0.0))
558        .add_stop(1.0, Rgba::rgb(0.0, 1.0, 0.2))
559}
560
561// ── Color harmonies ───────────────────────────────────────────────────────────
562
563/// Generate a complementary color (180° hue rotation).
564pub fn complementary(c: Rgba) -> Rgba {
565    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
566    let (r, g, b) = hsv_to_rgb((h + 180.0) % 360.0, s, v);
567    Rgba::rgb(r, g, b)
568}
569
570/// Generate split-complementary colors (150° and 210°).
571pub fn split_complementary(c: Rgba) -> (Rgba, Rgba) {
572    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
573    let mk = |dh: f32| {
574        let (r, g, b) = hsv_to_rgb((h + dh) % 360.0, s, v);
575        Rgba::rgb(r, g, b)
576    };
577    (mk(150.0), mk(210.0))
578}
579
580/// Generate triadic colors (120° apart).
581pub fn triadic(c: Rgba) -> (Rgba, Rgba) {
582    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
583    let mk = |dh: f32| {
584        let (r, g, b) = hsv_to_rgb((h + dh) % 360.0, s, v);
585        Rgba::rgb(r, g, b)
586    };
587    (mk(120.0), mk(240.0))
588}
589
590/// Generate analogous colors (±30°).
591pub fn analogous(c: Rgba) -> (Rgba, Rgba) {
592    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
593    let mk = |dh: f32| {
594        let (r, g, b) = hsv_to_rgb((h + dh + 360.0) % 360.0, s, v);
595        Rgba::rgb(r, g, b)
596    };
597    (mk(-30.0), mk(30.0))
598}
599
600/// Generate a tetradic (square) color scheme.
601pub fn tetradic(c: Rgba) -> [Rgba; 4] {
602    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
603    std::array::from_fn(|i| {
604        let (r, g, b) = hsv_to_rgb((h + i as f32 * 90.0) % 360.0, s, v);
605        Rgba::rgb(r, g, b)
606    })
607}
608
609// ── Palette types ─────────────────────────────────────────────────────────────
610
611/// A named palette of colors.
612#[derive(Debug, Clone)]
613pub struct Palette {
614    pub name:   String,
615    pub colors: Vec<Rgba>,
616}
617
618impl Palette {
619    pub fn new(name: impl Into<String>, colors: Vec<Rgba>) -> Self {
620        Self { name: name.into(), colors }
621    }
622
623    /// Sample the palette by index (wraps around).
624    pub fn get(&self, i: usize) -> Rgba {
625        if self.colors.is_empty() { return Rgba::WHITE; }
626        self.colors[i % self.colors.len()]
627    }
628
629    /// Sample interpolated between palette colors.
630    pub fn sample(&self, t: f32) -> Rgba {
631        if self.colors.is_empty() { return Rgba::WHITE; }
632        if self.colors.len() == 1 { return self.colors[0]; }
633        let t = t.fract().abs();
634        let f = t * (self.colors.len() - 1) as f32;
635        let i = f as usize;
636        let j = (i + 1).min(self.colors.len() - 1);
637        self.colors[i].lerp(self.colors[j], f.fract())
638    }
639}
640
641/// CRT terminal / retrowave palette.
642pub fn palette_crt() -> Palette {
643    Palette::new("CRT", vec![
644        Rgba::from_hex(0x00ff00), // phosphor green
645        Rgba::from_hex(0x00ffff), // cyan
646        Rgba::from_hex(0xff6600), // amber
647        Rgba::from_hex(0xffffff), // white
648    ])
649}
650
651/// ANSI 16-color terminal palette.
652pub fn palette_ansi16() -> Palette {
653    Palette::new("ANSI16", vec![
654        Rgba::from_hex(0x000000), Rgba::from_hex(0xaa0000),
655        Rgba::from_hex(0x00aa00), Rgba::from_hex(0xaa5500),
656        Rgba::from_hex(0x0000aa), Rgba::from_hex(0xaa00aa),
657        Rgba::from_hex(0x00aaaa), Rgba::from_hex(0xaaaaaa),
658        Rgba::from_hex(0x555555), Rgba::from_hex(0xff5555),
659        Rgba::from_hex(0x55ff55), Rgba::from_hex(0xffff55),
660        Rgba::from_hex(0x5555ff), Rgba::from_hex(0xff55ff),
661        Rgba::from_hex(0x55ffff), Rgba::from_hex(0xffffff),
662    ])
663}
664
665/// Chaos RPG element colors.
666pub fn palette_chaos_elements() -> Palette {
667    Palette::new("ChaosElements", vec![
668        Rgba::from_hex(0xff4400), // fire
669        Rgba::from_hex(0x00aaff), // water/ice
670        Rgba::from_hex(0x44ff44), // life
671        Rgba::from_hex(0xaa00ff), // shadow/void
672        Rgba::from_hex(0xffcc00), // lightning
673        Rgba::from_hex(0x22ffcc), // arcane
674        Rgba::from_hex(0xff00aa), // chaos
675    ])
676}
677
678// ── Tone mapping ──────────────────────────────────────────────────────────────
679
680/// Reinhard tone mapping operator.
681pub fn tonemap_reinhard(c: Rgba) -> Rgba {
682    let map = |x: f32| x / (x + 1.0);
683    Rgba::new(map(c.r), map(c.g), map(c.b), c.a)
684}
685
686/// ACES filmic tone mapping approximation (Narkowicz 2015).
687pub fn tonemap_aces(c: Rgba) -> Rgba {
688    let aces = |x: f32| -> f32 {
689        const A: f32 = 2.51;
690        const B: f32 = 0.03;
691        const C: f32 = 2.43;
692        const D: f32 = 0.59;
693        const E: f32 = 0.14;
694        ((x * (A * x + B)) / (x * (C * x + D) + E)).clamp(0.0, 1.0)
695    };
696    Rgba::new(aces(c.r), aces(c.g), aces(c.b), c.a)
697}
698
699/// Uncharted 2 "Hable" filmic tone mapping.
700pub fn tonemap_uncharted2(c: Rgba) -> Rgba {
701    fn partial(x: f32) -> f32 {
702        const A: f32 = 0.15; const B: f32 = 0.50;
703        const C: f32 = 0.10; const D: f32 = 0.20;
704        const E: f32 = 0.02; const F: f32 = 0.30;
705        ((x*(A*x+C*B)+D*E) / (x*(A*x+B)+D*F)) - E/F
706    }
707    let exposure_bias = 2.0_f32;
708    let curr = |x: f32| partial(x * exposure_bias);
709    let white = partial(11.2);
710    let scale = 1.0 / white;
711    Rgba::new(curr(c.r)*scale, curr(c.g)*scale, curr(c.b)*scale, c.a)
712}
713
714// ── Color distance ────────────────────────────────────────────────────────────
715
716/// Euclidean distance in linear RGB space.
717pub fn distance_rgb(a: Rgba, b: Rgba) -> f32 {
718    let dr = a.r - b.r; let dg = a.g - b.g; let db = a.b - b.b;
719    (dr*dr + dg*dg + db*db).sqrt()
720}
721
722/// Perceptual distance using CIE Lab Delta-E 1976.
723pub fn distance_lab_e76(a: Rgba, b: Rgba) -> f32 {
724    Lab::from_rgb(a).delta_e(&Lab::from_rgb(b))
725}
726
727/// Find the nearest color in a palette (by Lab Delta-E).
728pub fn nearest_in_palette(color: Rgba, palette: &Palette) -> usize {
729    let lab = Lab::from_rgb(color);
730    palette.colors.iter()
731        .enumerate()
732        .min_by(|(_, &a), (_, &b)| {
733            let da = lab.delta_e(&Lab::from_rgb(a));
734            let db = lab.delta_e(&Lab::from_rgb(b));
735            da.partial_cmp(&db).unwrap()
736        })
737        .map(|(i, _)| i)
738        .unwrap_or(0)
739}
740
741// ── Color adjustment ──────────────────────────────────────────────────────────
742
743/// Adjust hue by `delta` degrees.
744pub fn adjust_hue(c: Rgba, delta: f32) -> Rgba {
745    let (h, s, v) = rgb_to_hsv(c.r, c.g, c.b);
746    let (r, g, b) = hsv_to_rgb((h + delta + 360.0) % 360.0, s, v);
747    Rgba::new(r, g, b, c.a)
748}
749
750/// Saturate or desaturate (1 = no change, 0 = greyscale, >1 = boost).
751pub fn adjust_saturation(c: Rgba, factor: f32) -> Rgba {
752    let lum = c.luminance();
753    Rgba::new(
754        lum + (c.r - lum) * factor,
755        lum + (c.g - lum) * factor,
756        lum + (c.b - lum) * factor,
757        c.a,
758    )
759}
760
761/// Adjust brightness (additive offset).
762pub fn adjust_brightness(c: Rgba, delta: f32) -> Rgba {
763    Rgba::new((c.r + delta).clamp(0.0, 1.0),
764              (c.g + delta).clamp(0.0, 1.0),
765              (c.b + delta).clamp(0.0, 1.0),
766              c.a)
767}
768
769/// Adjust contrast around 0.5 midpoint (factor >1 = more contrast).
770pub fn adjust_contrast(c: Rgba, factor: f32) -> Rgba {
771    let adj = |x: f32| ((x - 0.5) * factor + 0.5).clamp(0.0, 1.0);
772    Rgba::new(adj(c.r), adj(c.g), adj(c.b), c.a)
773}
774
775/// Mix color `c` with white by `factor ∈ [0, 1]` (0 = original, 1 = white).
776pub fn tint_white(c: Rgba, factor: f32) -> Rgba {
777    c.lerp(Rgba::WHITE, factor)
778}
779
780/// Mix color `c` with black by `factor ∈ [0, 1]` (0 = original, 1 = black).
781pub fn shade_black(c: Rgba, factor: f32) -> Rgba {
782    c.lerp(Rgba::BLACK, factor)
783}
784
785// ── Tests ─────────────────────────────────────────────────────────────────────
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    fn approx_eq(a: f32, b: f32) -> bool { (a - b).abs() < 0.005 }
792
793    #[test]
794    fn hsv_roundtrip() {
795        let (h0, s0, v0) = (200.0f32, 0.7, 0.8);
796        let (r, g, b) = hsv_to_rgb(h0, s0, v0);
797        let (h1, s1, v1) = rgb_to_hsv(r, g, b);
798        assert!(approx_eq(h0, h1), "hue mismatch: {h0} vs {h1}");
799        assert!(approx_eq(s0, s1));
800        assert!(approx_eq(v0, v1));
801    }
802
803    #[test]
804    fn hsl_roundtrip() {
805        let (r, g, b) = hsl_to_rgb(120.0, 0.5, 0.5);
806        let (h, s, l) = rgb_to_hsl(r, g, b);
807        assert!(approx_eq(h, 120.0), "hue mismatch: {h}");
808        assert!(approx_eq(s, 0.5));
809        assert!(approx_eq(l, 0.5));
810    }
811
812    #[test]
813    fn oklab_roundtrip() {
814        let c = Rgba::from_hex(0x3a7bd5);
815        let oklab = Oklab::from_linear_rgb(c);
816        let back  = oklab.to_linear_rgb();
817        assert!(approx_eq(c.r, back.r), "r mismatch: {} vs {}", c.r, back.r);
818        assert!(approx_eq(c.g, back.g));
819        assert!(approx_eq(c.b, back.b));
820    }
821
822    #[test]
823    fn gradient_endpoints() {
824        let g = gradient_fire();
825        let lo = g.sample(0.0);
826        let hi = g.sample(1.0);
827        assert!(lo.luminance() < 0.01);
828        assert!(hi.luminance() > 0.9);
829    }
830
831    #[test]
832    fn complementary_is_180_degrees() {
833        let c = Rgba::from_hex(0xff0000); // red
834        let comp = complementary(c);
835        let (h, _, _) = rgb_to_hsv(comp.r, comp.g, comp.b);
836        // Complementary of red (0°) should be cyan (180°)
837        assert!((h - 180.0).abs() < 2.0, "hue={h}");
838    }
839
840    #[test]
841    fn tonemap_aces_bounds() {
842        let bright = Rgba::rgb(10.0, 5.0, 2.0); // HDR value
843        let tm = tonemap_aces(bright);
844        assert!(tm.r <= 1.0);
845        assert!(tm.g <= 1.0);
846        assert!(tm.b <= 1.0);
847    }
848}