Skip to main content

frus_core/
hct.rs

1//! **HCT** (Hue, Chroma, Tone): the Material 3 colour space, ported from
2//! `material-color-utilities` (Google). It pairs the perceptual hue and chroma
3//! of **CAM16** with CIELAB's **L\*** tone. Tone is what drives contrast: two
4//! colours 40 or more tones apart are guaranteed to render legible text.
5//!
6//! Two directions:
7//! - [`Hct::from_color`]: analysis (forward CAM16 plus L\*);
8//! - [`Hct::solve`]: synthesis — finds the displayable sRGB colour closest to
9//!   `(hue, chroma, tone)`. The requested chroma is a **ceiling**: out of
10//!   gamut, it is reduced by bisection, while hue and tone are preserved.
11//!
12//! [`TonalPalette`] spreads one hue and chroma across the tone scale — the
13//! building block of `ColorScheme::from_seed`.
14
15use crate::Color;
16
17// --- CIE: Y (relative luminance) <-> L* (perceptual tone) ------------------
18
19/// `L*` (0..100) from the relative luminance `Y` (0..100).
20fn lstar_from_y(y: f64) -> f64 {
21    let e = 216.0 / 24389.0;
22    let yn = y / 100.0;
23    if yn <= e {
24        (24389.0 / 27.0) * yn
25    } else {
26        116.0 * yn.cbrt() - 16.0
27    }
28}
29
30/// Relative luminance `Y` (0..100) from `L*` (0..100).
31fn y_from_lstar(lstar: f64) -> f64 {
32    let ft = (lstar + 16.0) / 116.0;
33    let ft3 = ft * ft * ft;
34    if ft3 > 216.0 / 24389.0 {
35        ft3 * 100.0
36    } else {
37        lstar / (24389.0 / 27.0) * 100.0
38    }
39}
40
41// --- sRGB (components 0..1) <-> XYZ (0..100 scale, D65) --------------------
42
43fn linearized(c: f64) -> f64 {
44    // sRGB -> linear, output on 0..100.
45    if c <= 0.040449936 {
46        c / 12.92 * 100.0
47    } else {
48        ((c + 0.055) / 1.055).powf(2.4) * 100.0
49    }
50}
51
52fn delinearized(c: f64) -> f64 {
53    // Linear 0..100 -> sRGB 0..1, clamped to the gamut.
54    let normalized = (c / 100.0).clamp(0.0, 1.0);
55    if normalized <= 0.0031308 {
56        normalized * 12.92
57    } else {
58        1.055 * normalized.powf(1.0 / 2.4) - 0.055
59    }
60}
61
62/// sRGB -> XYZ (D65), with XYZ components on 0..100.
63fn xyz_from_color(color: Color) -> [f64; 3] {
64    let r = linearized(color.r as f64);
65    let g = linearized(color.g as f64);
66    let b = linearized(color.b as f64);
67    [
68        0.41233895 * r + 0.35762064 * g + 0.18051042 * b,
69        0.2126 * r + 0.7152 * g + 0.0722 * b,
70        0.01932141 * r + 0.11916382 * g + 0.95034478 * b,
71    ]
72}
73
74/// **Linear** RGB (0..100) -> an sRGB [`Color`], clamped to the gamut.
75fn color_from_linrgb(rgb: [f64; 3]) -> Color {
76    Color::rgb(
77        delinearized(rgb[0]) as f32,
78        delinearized(rgb[1]) as f32,
79        delinearized(rgb[2]) as f32,
80    )
81}
82
83// --- CAM16: the standard viewing conditions (sRGB) --------------------------
84
85/// The default viewing conditions of `material-color-utilities`: D65 white,
86/// adapting luminance about 11.73 cd/m2, an L\* 50 background, average surround.
87struct ViewingConditions {
88    aw: f64,
89    nbb: f64,
90    ncb: f64,
91    c: f64,
92    nc: f64,
93    n: f64,
94    rgb_d: [f64; 3],
95    fl: f64,
96    z: f64,
97}
98
99fn default_viewing_conditions() -> ViewingConditions {
100    let white_point = [95.047, 100.0, 108.883]; // D65
101    let adapting_luminance = (200.0 / std::f64::consts::PI) * (y_from_lstar(50.0) / 100.0);
102    let background_lstar = 50.0;
103    let surround = 2.0;
104
105    // The white point in cone space (the CAT16 matrix).
106    let r_w = white_point[0] * 0.401288 + white_point[1] * 0.650173 + white_point[2] * -0.051461;
107    let g_w = white_point[0] * -0.250268 + white_point[1] * 1.204414 + white_point[2] * 0.045854;
108    let b_w = white_point[0] * -0.002079 + white_point[1] * 0.048952 + white_point[2] * 0.953127;
109
110    let f = 0.8 + surround / 10.0;
111    let c = if f >= 0.9 {
112        // lerp(0.59, 0.69, (f-0.9)*10)
113        0.59 + (0.69 - 0.59) * ((f - 0.9) * 10.0)
114    } else {
115        0.525 + (0.59 - 0.525) * (f / 0.9)
116    };
117    let nc = f;
118    let d = (f * (1.0 - (1.0 / 3.6) * ((-adapting_luminance - 42.0) / 92.0).exp())).clamp(0.0, 1.0);
119    let rgb_d = [
120        d * (100.0 / r_w) + 1.0 - d,
121        d * (100.0 / g_w) + 1.0 - d,
122        d * (100.0 / b_w) + 1.0 - d,
123    ];
124    let k = 1.0 / (5.0 * adapting_luminance + 1.0);
125    let k4 = k * k * k * k;
126    let k4f = 1.0 - k4;
127    let fl = k4 * adapting_luminance + 0.1 * k4f * k4f * (5.0 * adapting_luminance).cbrt();
128    let n = y_from_lstar(background_lstar) / white_point[1];
129    let z = 1.48 + n.sqrt();
130    let nbb = 0.725 / n.powf(0.2);
131    let ncb = nbb;
132
133    let adapt = |cone: f64, dc: f64| {
134        let af = (fl * dc * cone / 100.0).powf(0.42);
135        400.0 * af / (af + 27.13)
136    };
137    let r_a = adapt(r_w, rgb_d[0]);
138    let g_a = adapt(g_w, rgb_d[1]);
139    let b_a = adapt(b_w, rgb_d[2]);
140    let aw = (2.0 * r_a + g_a + 0.05 * b_a) * nbb;
141
142    ViewingConditions {
143        aw,
144        nbb,
145        ncb,
146        c,
147        nc,
148        n,
149        rgb_d,
150        fl,
151        z,
152    }
153}
154
155/// A colour's CAM16 hue (degrees) and chroma, under the standard conditions.
156fn cam16_hue_chroma(color: Color) -> (f64, f64) {
157    let vc = default_viewing_conditions();
158    let [x, y, z] = xyz_from_color(color);
159
160    // Cones (CAT16): chromatic adaptation, then compression.
161    let r_c = 0.401288 * x + 0.650173 * y - 0.051461 * z;
162    let g_c = -0.250268 * x + 1.204414 * y + 0.045854 * z;
163    let b_c = -0.002079 * x + 0.048952 * y + 0.953127 * z;
164    let adapt = |cone: f64, dc: f64| {
165        let scaled = vc.fl * dc * cone / 100.0;
166        let af = scaled.abs().powf(0.42);
167        scaled.signum() * 400.0 * af / (af + 27.13)
168    };
169    let r_a = adapt(r_c, vc.rgb_d[0]);
170    let g_a = adapt(g_c, vc.rgb_d[1]);
171    let b_a = adapt(b_c, vc.rgb_d[2]);
172
173    // The opponent axes a (red-green) and b (yellow-blue), and the hue.
174    let a = (11.0 * r_a + -12.0 * g_a + b_a) / 11.0;
175    let b_axis = (r_a + g_a - 2.0 * b_a) / 9.0;
176    let u = (20.0 * r_a + 20.0 * g_a + 21.0 * b_a) / 20.0;
177    let p2 = (40.0 * r_a + 20.0 * g_a + b_a) / 20.0;
178    let atan_deg = b_axis.atan2(a).to_degrees();
179    let hue = if atan_deg < 0.0 {
180        atan_deg + 360.0
181    } else {
182        atan_deg
183    };
184
185    // Lightness J, then chroma.
186    let ac = p2 * vc.nbb;
187    let j = 100.0 * (ac / vc.aw).powf(vc.c * vc.z);
188    let hue_prime = if hue < 20.14 { hue + 360.0 } else { hue };
189    let e_hue = 0.25 * ((hue_prime.to_radians() + 2.0).cos() + 3.8);
190    let p1 = 50000.0 / 13.0 * e_hue * vc.nc * vc.ncb;
191    let t = p1 * a.hypot(b_axis) / (u + 0.305);
192    let alpha = t.powf(0.9) * (1.64 - 0.29_f64.powf(vc.n)).powf(0.73);
193    let chroma = alpha * (j / 100.0).sqrt();
194    (hue, chroma)
195}
196
197// --- Solver: (hue, chroma, tone) -> sRGB ------------------------------------
198
199/// The "linear 0..100" matrix, from the rescaled adapted cones (constants taken
200/// from material-color-utilities' HctSolver).
201const LINRGB_FROM_SCALED_DISCOUNT: [[f64; 3]; 3] = [
202    [1373.2198709594231, -1100.4251190754821, -7.278681089101213],
203    [-271.815969077903, 559.6580465940733, -32.46047482791194],
204    [1.9622899599665666, -57.173814538844006, 308.7233197812385],
205];
206const Y_FROM_LINRGB: [f64; 3] = [0.2126, 0.7152, 0.0722];
207
208/// The neutral grey of tone `tone` (zero chroma).
209fn gray(tone: f64) -> Color {
210    let y = y_from_lstar(tone);
211    // The target Y, applied to all three identical linear components.
212    let component = delinearized(y) as f32;
213    Color::rgb(component, component, component)
214}
215
216/// Attempts to solve `(hue, chroma, Y)` by iterating on lightness `J` (five
217/// Newton steps on Y). `None` when the result falls outside the sRGB gamut.
218fn find_result_by_j(hue_radians: f64, chroma: f64, y_target: f64) -> Option<Color> {
219    let vc = default_viewing_conditions();
220    let mut j = y_target.sqrt() * 11.0;
221
222    let t_inner_coeff = 1.0 / (1.64 - 0.29_f64.powf(vc.n)).powf(0.73);
223    let e_hue = 0.25 * ((hue_radians + 2.0).cos() + 3.8);
224    let p1 = e_hue * (50000.0 / 13.0) * vc.nc * vc.ncb;
225    let h_sin = hue_radians.sin();
226    let h_cos = hue_radians.cos();
227
228    for iteration in 0..5 {
229        let j_normalized = j / 100.0;
230        let alpha = if chroma == 0.0 || j == 0.0 {
231            0.0
232        } else {
233            chroma / j_normalized.sqrt()
234        };
235        let t = (alpha * t_inner_coeff).powf(1.0 / 0.9);
236        let ac = vc.aw * j_normalized.powf(1.0 / (vc.c * vc.z));
237        let p2 = ac / vc.nbb;
238        let gamma = 23.0 * (p2 + 0.305) * t / (23.0 * p1 + 11.0 * t * h_cos + 108.0 * t * h_sin);
239        let a = gamma * h_cos;
240        let b = gamma * h_sin;
241        let r_a = (460.0 * p2 + 451.0 * a + 288.0 * b) / 1403.0;
242        let g_a = (460.0 * p2 - 891.0 * a - 261.0 * b) / 1403.0;
243        let b_a = (460.0 * p2 - 220.0 * a - 6300.0 * b) / 1403.0;
244
245        // Decompress the adapted cones (the inverse of the 0.42 compression).
246        let inverse_adapt = |adapted: f64| {
247            let adapted_abs = adapted.abs();
248            let base = (27.13 * adapted_abs / (400.0 - adapted_abs)).max(0.0);
249            adapted.signum() * base.powf(1.0 / 0.42)
250        };
251        let r_c = inverse_adapt(r_a);
252        let g_c = inverse_adapt(g_a);
253        let b_c = inverse_adapt(b_a);
254
255        let m = &LINRGB_FROM_SCALED_DISCOUNT;
256        let lin = [
257            m[0][0] * r_c + m[0][1] * g_c + m[0][2] * b_c,
258            m[1][0] * r_c + m[1][1] * g_c + m[1][2] * b_c,
259            m[2][0] * r_c + m[2][1] * g_c + m[2][2] * b_c,
260        ];
261        if lin[0] < 0.0 || lin[1] < 0.0 || lin[2] < 0.0 {
262            return None;
263        }
264        let fnj = Y_FROM_LINRGB[0] * lin[0] + Y_FROM_LINRGB[1] * lin[1] + Y_FROM_LINRGB[2] * lin[2];
265        if fnj <= 0.0 {
266            return None;
267        }
268        if iteration == 4 || (fnj - y_target).abs() < 0.002 {
269            if lin[0] > 100.01 || lin[1] > 100.01 || lin[2] > 100.01 {
270                return None;
271            }
272            return Some(color_from_linrgb(lin));
273        }
274        // A Newton step on Y (f(J) ~ Y, dY/dJ ~ 2Y/J).
275        j -= (fnj - y_target) * j / (2.0 * fnj);
276    }
277    None
278}
279
280/// A colour in **HCT**: CAM16 hue (degrees), CAM16 chroma, and L\* tone.
281#[derive(Clone, Copy, Debug, PartialEq)]
282pub struct Hct {
283    /// Hue, in degrees (0..360).
284    pub hue: f64,
285    /// Chroma (0 = grey; the maximum depends on the hue and the tone).
286    pub chroma: f64,
287    /// Tone `L*` (0 = black, 100 = white) — the contrast axis.
288    pub tone: f64,
289}
290
291impl Hct {
292    /// Analyses an sRGB colour into HCT.
293    pub fn from_color(color: Color) -> Self {
294        let (hue, chroma) = cam16_hue_chroma(color);
295        let tone = lstar_from_y(xyz_from_color(color)[1]);
296        Self { hue, chroma, tone }
297    }
298
299    /// Finds the displayable sRGB colour for `(hue, chroma, tone)`. Chroma is
300    /// reduced where necessary; hue and tone are preserved.
301    pub fn solve(hue: f64, chroma: f64, tone: f64) -> Color {
302        // Degenerate cases: pure grey, where tone alone decides.
303        if chroma < 1e-4 || !(1e-4..=99.9999).contains(&tone) {
304            return gray(tone.clamp(0.0, 100.0));
305        }
306        let hue = hue.rem_euclid(360.0);
307        let hue_radians = hue.to_radians();
308        let y = y_from_lstar(tone);
309
310        if let Some(exact) = find_result_by_j(hue_radians, chroma, y) {
311            return exact;
312        }
313        // Out of gamut: bisect on chroma, to a precision of 0.4 — the same as
314        // the original material-color-utilities solver.
315        let (mut low, mut high) = (0.0_f64, chroma);
316        let mut answer = gray(tone);
317        while high - low > 0.4 {
318            let mid = (low + high) / 2.0;
319            match find_result_by_j(hue_radians, mid, y) {
320                Some(color) => {
321                    answer = color;
322                    low = mid;
323                }
324                None => high = mid,
325            }
326        }
327        answer
328    }
329}
330
331/// A **tonal palette**: one hue and chroma spread across the tone scale.
332/// `tone(90)` is very light, `tone(10)` very dark, and both read as one colour.
333#[derive(Clone, Copy, Debug, PartialEq)]
334pub struct TonalPalette {
335    /// The hue, in degrees.
336    pub hue: f64,
337    /// The chroma ceiling: the maximum saturation reachable at any tone.
338    pub chroma: f64,
339}
340
341impl TonalPalette {
342    /// A palette of hue `hue` (degrees), with `chroma` as the chroma ceiling.
343    pub fn new(hue: f64, chroma: f64) -> Self {
344        Self { hue, chroma }
345    }
346
347    /// This palette's colour at tone `tone` (0..100).
348    pub fn tone(&self, tone: f64) -> Color {
349        Hct::solve(self.hue, self.chroma, tone)
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    fn assert_close(value: f64, expected: f64, tolerance: f64, label: &str) {
358        assert!(
359            (value - expected).abs() <= tolerance,
360            "{label}: {value}, expected ≈ {expected} (± {tolerance})"
361        );
362    }
363
364    #[test]
365    fn tone_matches_lstar() {
366        // White, black and mid grey: the tone is CIELAB's L*.
367        assert_close(Hct::from_color(Color::WHITE).tone, 100.0, 0.1, "ton blanc");
368        assert_close(Hct::from_color(Color::BLACK).tone, 0.0, 0.1, "ton noir");
369        // sRGB 0.5 -> L* about 53.59 (material grey: #808080 -> 53.585).
370        let mid = Hct::from_color(Color::rgb8(0x80, 0x80, 0x80));
371        assert_close(mid.tone, 53.585, 0.2, "ton gris moyen");
372        // Under partial adaptation CAM16 leaves a residual chroma of about 1.9 on
373        // greys (reference: materialyoucolor gives 1.896). This is not a bug.
374        assert_close(mid.chroma, 1.896, 0.1, "grey's residual chroma");
375    }
376
377    #[test]
378    fn google_blue_analyzes_to_known_hct() {
379        // #4285F4: the material-color-utilities reference values (hue 265.979
380        // degrees, chroma 62.269, tone 56.550), checked against the Python port
381        // `materialyoucolor`.
382        let hct = Hct::from_color(Color::rgb8(0x42, 0x85, 0xF4));
383        assert_close(hct.hue, 265.979, 0.2, "teinte bleu Google");
384        assert_close(hct.chroma, 62.269, 0.2, "chroma bleu Google");
385        assert_close(hct.tone, 56.55, 0.2, "ton bleu Google");
386    }
387
388    #[test]
389    fn solve_round_trips_in_gamut_colors() {
390        // Targets well inside the gamut: hue and tone should come back almost
391        // exactly, chroma to the solver's precision.
392        for &(hue, chroma, tone) in &[
393            (27.0, 16.0, 50.0),
394            (120.0, 24.0, 70.0),
395            (282.0, 40.0, 40.0),
396            (200.0, 8.0, 90.0),
397        ] {
398            let color = Hct::solve(hue, chroma, tone);
399            let round = Hct::from_color(color);
400            assert_close(round.hue, hue, 4.0, "teinte round-trip");
401            assert_close(round.chroma, chroma, 2.5, "chroma round-trip");
402            assert_close(round.tone, tone, 0.5, "ton round-trip");
403        }
404    }
405
406    #[test]
407    fn solve_clamps_impossible_chroma_but_keeps_hue_and_tone() {
408        // No hue has a chroma of 200: the solver must return the most chromatic
409        // colour in the gamut, preserving hue and tone.
410        let color = Hct::solve(265.0, 200.0, 30.0);
411        let round = Hct::from_color(color);
412        assert_close(round.tone, 30.0, 1.0, "tone preserved out of gamut");
413        assert_close(round.hue, 265.0, 4.0, "hue preserved out of gamut");
414        assert!(
415            round.chroma > 20.0,
416            "chroma maximal atteint ({})",
417            round.chroma
418        );
419    }
420
421    #[test]
422    fn solve_matches_reference_implementation() {
423        // Output of the Python port `materialyoucolor` (Google's HctSolver), to
424        // within 1/255 per channel (implementation rounding).
425        // Tolerance: +/-1 inside the gamut, +/-3 outside it — our chroma bisection
426        // has a precision of 0.4 where Google bisects the exact gamut boundary.
427        let cases: [(f64, f64, f64, [u8; 3], i32); 3] = [
428            (27.0, 16.0, 50.0, [0x92, 0x6F, 0x69], 1),
429            (120.0, 24.0, 70.0, [0xA8, 0xB0, 0x7E], 1),
430            (265.0, 200.0, 30.0, [0x00, 0x44, 0x91], 3), // out of gamut -> capped
431        ];
432        for (hue, chroma, tone, expected, tolerance) in cases {
433            let color = Hct::solve(hue, chroma, tone);
434            let actual = [
435                (color.r * 255.0).round() as i32,
436                (color.g * 255.0).round() as i32,
437                (color.b * 255.0).round() as i32,
438            ];
439            for (a, e) in actual.iter().zip(expected.iter()) {
440                assert!(
441                    (a - *e as i32).abs() <= tolerance,
442                    "solve({hue},{chroma},{tone}) = {actual:?}, reference {expected:?}"
443                );
444            }
445        }
446    }
447
448    #[test]
449    fn tonal_palette_is_monotonic_in_luminance() {
450        let palette = TonalPalette::new(282.0, 48.0);
451        let mut previous = -1.0_f32;
452        for tone in [0.0, 10.0, 20.0, 40.0, 60.0, 80.0, 90.0, 99.0, 100.0] {
453            let luminance = palette.tone(tone).compute_luminance();
454            assert!(
455                luminance >= previous,
456                "luminance non monotone au ton {tone} : {luminance} < {previous}"
457            );
458            previous = luminance;
459        }
460        // Extremes: tone 0 is black, tone 100 is white; chroma is unreachable.
461        assert!(palette.tone(0.0).compute_luminance() < 1e-3);
462        assert!(palette.tone(100.0).compute_luminance() > 0.99);
463    }
464
465    #[test]
466    fn degenerate_inputs_do_not_produce_nan() {
467        for color in [
468            Hct::solve(0.0, 0.0, 50.0),
469            Hct::solve(-90.0, 30.0, 50.0), // negative hue, normalised
470            Hct::solve(400.0, 30.0, 101.0),
471            Hct::solve(120.0, 30.0, -5.0),
472        ] {
473            assert!(color.r.is_finite() && color.g.is_finite() && color.b.is_finite());
474            assert!((0.0..=1.0).contains(&color.r));
475        }
476    }
477}