Skip to main content

gpui_kit_material_color/dynamic/
hct.rs

1// Copyright 2021 Google LLC
2// SPDX-License-Identifier: Apache-2.0
3// Ported to Rust from material-color-utilities (typescript/hct), modified for
4// this crate. See licenses/material-color-utilities.txt.
5
6//! CAM16, its viewing conditions and the HCT color space.
7use super::solver;
8use super::utils::{
9    WHITE_POINT_D65, argb_from_xyz, blue_from_argb, clamp_double, green_from_argb, lerp,
10    linearized, lstar_from_argb, lstar_from_y, red_from_argb, sanitize_degrees_double, signum,
11    y_from_lstar,
12};
13use std::f64::consts::PI;
14use std::sync::LazyLock;
15
16/// The environment a color is seen in, with the CAM16 intermediates that
17/// depend only on it. Field names are CAM16's own terms.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct ViewingConditions {
20    /// Background induction factor.
21    pub n: f64,
22    /// Achromatic response of the white point.
23    pub aw: f64,
24    /// Brightness induction factor.
25    pub nbb: f64,
26    /// Chromatic induction factor.
27    pub ncb: f64,
28    /// Impact of surround.
29    pub c: f64,
30    /// Chromatic induction from surround.
31    pub nc: f64,
32    /// Per-channel degree of adaptation.
33    pub rgb_d: [f64; 3],
34    /// Luminance-level adaptation factor.
35    pub fl: f64,
36    /// Fourth root of [`Self::fl`].
37    pub fl_root: f64,
38    /// Base exponential nonlinearity.
39    pub z: f64,
40}
41
42static DEFAULT_CONDITIONS: LazyLock<ViewingConditions> = LazyLock::new(|| {
43    ViewingConditions::make(
44        WHITE_POINT_D65,
45        (200. / PI) * y_from_lstar(50.) / 100.,
46        50.,
47        2.,
48        false,
49    )
50});
51
52impl ViewingConditions {
53    /// sRGB-like viewing conditions: the ones HCT is defined in.
54    pub fn standard() -> &'static Self {
55        &DEFAULT_CONDITIONS
56    }
57
58    /// Conditions from physical parameters: the white point in XYZ, the
59    /// adapting luminance, the L* of the surrounding area, the surround (0
60    /// dark, 1 dim, 2 average) and whether the eye discounts the illuminant.
61    pub fn make(
62        white_point: [f64; 3],
63        adapting_luminance: f64,
64        background_lstar: f64,
65        surround: f64,
66        discounting_illuminant: bool,
67    ) -> Self {
68        let [x, y, z] = white_point;
69        let r_w = x * 0.401288 + y * 0.650173 + z * -0.051461;
70        let g_w = x * -0.250268 + y * 1.204414 + z * 0.045854;
71        let b_w = x * -0.002079 + y * 0.048952 + z * 0.953127;
72        let f = 0.8 + surround / 10.;
73        let c = if f >= 0.9 {
74            lerp(0.59, 0.69, (f - 0.9) * 10.)
75        } else {
76            lerp(0.525, 0.59, (f - 0.8) * 10.)
77        };
78        let d = if discounting_illuminant {
79            1.
80        } else {
81            f * (1. - (1. / 3.6) * ((-adapting_luminance - 42.) / 92.).exp())
82        };
83        let d = clamp_double(0., 1., d);
84        let rgb_d = [
85            d * (100. / r_w) + 1. - d,
86            d * (100. / g_w) + 1. - d,
87            d * (100. / b_w) + 1. - d,
88        ];
89        let k = 1. / (5. * adapting_luminance + 1.);
90        let k4 = k * k * k * k;
91        let k4f = 1. - k4;
92        let fl = k4 * adapting_luminance + 0.1 * k4f * k4f * (5. * adapting_luminance).cbrt();
93        let n = y_from_lstar(background_lstar) / y;
94        let z_base = 1.48 + n.sqrt();
95        let nbb = 0.725 / n.powf(0.2);
96        let factors = [
97            ((fl * rgb_d[0] * r_w) / 100.).powf(0.42),
98            ((fl * rgb_d[1] * g_w) / 100.).powf(0.42),
99            ((fl * rgb_d[2] * b_w) / 100.).powf(0.42),
100        ];
101        let rgb_a = factors.map(|factor| (400. * factor) / (factor + 27.13));
102        let aw = (2. * rgb_a[0] + rgb_a[1] + 0.05 * rgb_a[2]) * nbb;
103        Self {
104            n,
105            aw,
106            nbb,
107            ncb: nbb,
108            c,
109            nc: f,
110            rgb_d,
111            fl,
112            fl_root: fl.powf(0.25),
113            z: z_base,
114        }
115    }
116}
117
118/// A color in CAM16, the appearance model behind HCT's hue and chroma.
119#[derive(Clone, Copy, Debug, PartialEq)]
120pub struct Cam16 {
121    /// Hue in degrees.
122    pub hue: f64,
123    /// Chroma: colorfulness relative to white.
124    pub chroma: f64,
125    /// Lightness.
126    pub j: f64,
127    /// Brightness.
128    pub q: f64,
129    /// Colorfulness.
130    pub m: f64,
131    /// Saturation.
132    pub s: f64,
133    /// CAM16-UCS J coordinate.
134    pub jstar: f64,
135    /// CAM16-UCS a coordinate.
136    pub astar: f64,
137    /// CAM16-UCS b coordinate.
138    pub bstar: f64,
139}
140
141impl Cam16 {
142    /// Perceptual distance in CAM16-UCS.
143    pub fn distance(&self, other: &Self) -> f64 {
144        let d_j = self.jstar - other.jstar;
145        let d_a = self.astar - other.astar;
146        let d_b = self.bstar - other.bstar;
147        1.41 * (d_j * d_j + d_a * d_a + d_b * d_b).sqrt().powf(0.63)
148    }
149
150    /// An ARGB color in the standard viewing conditions.
151    pub fn from_argb(argb: u32) -> Self {
152        let red = linearized(red_from_argb(argb));
153        let green = linearized(green_from_argb(argb));
154        let blue = linearized(blue_from_argb(argb));
155        let x = 0.41233895 * red + 0.35762064 * green + 0.18051042 * blue;
156        let y = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
157        let z = 0.01932141 * red + 0.11916382 * green + 0.95034478 * blue;
158        Self::from_xyz_in(x, y, z, ViewingConditions::standard())
159    }
160
161    /// An XYZ color seen in `vc`.
162    pub fn from_xyz_in(x: f64, y: f64, z: f64, vc: &ViewingConditions) -> Self {
163        let r_c = 0.401288 * x + 0.650173 * y - 0.051461 * z;
164        let g_c = -0.250268 * x + 1.204414 * y + 0.045854 * z;
165        let b_c = -0.002079 * x + 0.048952 * y + 0.953127 * z;
166        let r_d = vc.rgb_d[0] * r_c;
167        let g_d = vc.rgb_d[1] * g_c;
168        let b_d = vc.rgb_d[2] * b_c;
169        let adapt = |d: f64| {
170            let af = (vc.fl * d.abs() / 100.).powf(0.42);
171            signum(d) * 400. * af / (af + 27.13)
172        };
173        let (r_a, g_a, b_a) = (adapt(r_d), adapt(g_d), adapt(b_d));
174        let a = (11. * r_a + -12. * g_a + b_a) / 11.;
175        let b = (r_a + g_a - 2. * b_a) / 9.;
176        let u = (20. * r_a + 20. * g_a + 21. * b_a) / 20.;
177        let p2 = (40. * r_a + 20. * g_a + b_a) / 20.;
178        let hue = sanitize_degrees_double(b.atan2(a) * 180. / PI);
179        let hue_radians = hue * PI / 180.;
180        let ac = p2 * vc.nbb;
181        let j = 100. * (ac / vc.aw).powf(vc.c * vc.z);
182        let q = (4. / vc.c) * (j / 100.).sqrt() * (vc.aw + 4.) * vc.fl_root;
183        let hue_prime = if hue < 20.14 { hue + 360. } else { hue };
184        let e_hue = 0.25 * ((hue_prime * PI / 180. + 2.).cos() + 3.8);
185        let p1 = (50000. / 13.) * e_hue * vc.nc * vc.ncb;
186        let t = (p1 * (a * a + b * b).sqrt()) / (u + 0.305);
187        let alpha = t.powf(0.9) * (1.64 - 0.29_f64.powf(vc.n)).powf(0.73);
188        let c = alpha * (j / 100.).sqrt();
189        let m = c * vc.fl_root;
190        let s = 50. * ((alpha * vc.c) / (vc.aw + 4.)).sqrt();
191        let jstar = ((1. + 100. * 0.007) * j) / (1. + 0.007 * j);
192        let mstar = (1. / 0.0228) * (1. + 0.0228 * m).ln();
193        Self {
194            hue,
195            chroma: c,
196            j,
197            q,
198            m,
199            s,
200            jstar,
201            astar: mstar * hue_radians.cos(),
202            bstar: mstar * hue_radians.sin(),
203        }
204    }
205
206    /// From lightness, chroma and hue in the standard viewing conditions.
207    pub fn from_jch(j: f64, c: f64, h: f64) -> Self {
208        Self::from_jch_in(j, c, h, ViewingConditions::standard())
209    }
210
211    /// From lightness, chroma and hue in `vc`.
212    pub fn from_jch_in(j: f64, c: f64, h: f64, vc: &ViewingConditions) -> Self {
213        let q = (4. / vc.c) * (j / 100.).sqrt() * (vc.aw + 4.) * vc.fl_root;
214        let m = c * vc.fl_root;
215        let alpha = c / (j / 100.).sqrt();
216        let s = 50. * ((alpha * vc.c) / (vc.aw + 4.)).sqrt();
217        let hue_radians = h * PI / 180.;
218        let jstar = ((1. + 100. * 0.007) * j) / (1. + 0.007 * j);
219        let mstar = (1. / 0.0228) * (1. + 0.0228 * m).ln();
220        Self {
221            hue: h,
222            chroma: c,
223            j,
224            q,
225            m,
226            s,
227            jstar,
228            astar: mstar * hue_radians.cos(),
229            bstar: mstar * hue_radians.sin(),
230        }
231    }
232
233    /// From CAM16-UCS coordinates in the standard viewing conditions.
234    pub fn from_ucs(jstar: f64, astar: f64, bstar: f64) -> Self {
235        let vc = ViewingConditions::standard();
236        let m = (astar * astar + bstar * bstar).sqrt();
237        let big_m = ((m * 0.0228).exp() - 1.) / 0.0228;
238        let c = big_m / vc.fl_root;
239        let mut h = bstar.atan2(astar) * (180. / PI);
240        if h < 0. {
241            h += 360.;
242        }
243        let j = jstar / (1. - (jstar - 100.) * 0.007);
244        Self::from_jch_in(j, c, h, vc)
245    }
246
247    /// The ARGB color in the standard viewing conditions.
248    pub fn to_argb(&self) -> u32 {
249        let [x, y, z] = self.xyz_in(ViewingConditions::standard());
250        argb_from_xyz(x, y, z)
251    }
252
253    /// The XYZ coordinates of this color seen in `vc`.
254    pub fn xyz_in(&self, vc: &ViewingConditions) -> [f64; 3] {
255        let alpha = if self.chroma == 0. || self.j == 0. {
256            0.
257        } else {
258            self.chroma / (self.j / 100.).sqrt()
259        };
260        let t = (alpha / (1.64 - 0.29_f64.powf(vc.n)).powf(0.73)).powf(1. / 0.9);
261        let h_rad = self.hue * PI / 180.;
262        let e_hue = 0.25 * ((h_rad + 2.).cos() + 3.8);
263        let ac = vc.aw * (self.j / 100.).powf(1. / vc.c / vc.z);
264        let p1 = e_hue * (50000. / 13.) * vc.nc * vc.ncb;
265        let p2 = ac / vc.nbb;
266        let (h_sin, h_cos) = h_rad.sin_cos();
267        let gamma = (23. * (p2 + 0.305) * t) / (23. * p1 + 11. * t * h_cos + 108. * t * h_sin);
268        let a = gamma * h_cos;
269        let b = gamma * h_sin;
270        let r_a = (460. * p2 + 451. * a + 288. * b) / 1403.;
271        let g_a = (460. * p2 - 891. * a - 261. * b) / 1403.;
272        let b_a = (460. * p2 - 220. * a - 6300. * b) / 1403.;
273        let unadapt = |adapted: f64| {
274            let base = ((27.13 * adapted.abs()) / (400. - adapted.abs())).max(0.);
275            signum(adapted) * (100. / vc.fl) * base.powf(1. / 0.42)
276        };
277        let r_f = unadapt(r_a) / vc.rgb_d[0];
278        let g_f = unadapt(g_a) / vc.rgb_d[1];
279        let b_f = unadapt(b_a) / vc.rgb_d[2];
280        [
281            1.86206786 * r_f - 1.01125463 * g_f + 0.14918677 * b_f,
282            0.38752654 * r_f + 0.62144744 * g_f - 0.00897398 * b_f,
283            -0.01584150 * r_f - 0.03412294 * g_f + 1.04996444 * b_f,
284        ]
285    }
286}
287
288/// Hue, chroma and tone: CAM16 hue and chroma with L* as tone, so a tone
289/// difference predicts contrast (40 gives at least 3:1, 50 at least 4.5:1).
290#[derive(Clone, Copy, Debug, PartialEq)]
291pub struct Hct {
292    hue: f64,
293    chroma: f64,
294    tone: f64,
295    argb: u32,
296}
297
298impl Hct {
299    /// The sRGB color closest to the request. Hue and tone are met when
300    /// possible and chroma is reduced to what they allow. Non-finite inputs
301    /// count as 0.
302    pub fn from_hct(hue: f64, chroma: f64, tone: f64) -> Self {
303        let finite = |value: f64| if value.is_finite() { value } else { 0. };
304        Self::from_argb(solver::solve_to_argb(
305            finite(hue),
306            finite(chroma),
307            finite(tone),
308        ))
309    }
310
311    /// An ARGB color. The alpha channel is ignored for measurement and kept
312    /// in [`Self::argb`].
313    pub fn from_argb(argb: u32) -> Self {
314        let cam = Cam16::from_argb(argb);
315        Self {
316            hue: cam.hue,
317            chroma: cam.chroma,
318            tone: lstar_from_argb(argb),
319            argb,
320        }
321    }
322
323    /// Hue in `0.0..360.0`.
324    pub fn hue(&self) -> f64 {
325        self.hue
326    }
327
328    /// Chroma, whose maximum depends on hue and tone.
329    pub fn chroma(&self) -> f64 {
330        self.chroma
331    }
332
333    /// Tone (L*) in `0.0..=100.0`.
334    pub fn tone(&self) -> f64 {
335        self.tone
336    }
337
338    /// The color as ARGB.
339    pub fn argb(&self) -> u32 {
340        self.argb
341    }
342
343    /// Whether `hue` is in upstream's blue range, `250..270`.
344    pub fn is_blue(hue: f64) -> bool {
345        (250. ..270.).contains(&hue)
346    }
347
348    /// Whether `hue` is in upstream's yellow range, `105..125`.
349    pub fn is_yellow(hue: f64) -> bool {
350        (105. ..125.).contains(&hue)
351    }
352
353    /// Whether `hue` is in upstream's cyan range, `170..207`.
354    pub fn is_cyan(hue: f64) -> bool {
355        (170. ..207.).contains(&hue)
356    }
357
358    /// This color as it would appear in `vc`, expressed in the standard
359    /// viewing conditions.
360    pub fn in_viewing_conditions(&self, vc: &ViewingConditions) -> Self {
361        let [x, y, z] = Cam16::from_argb(self.argb).xyz_in(vc);
362        let recast = Cam16::from_xyz_in(x, y, z, ViewingConditions::standard());
363        Self::from_hct(recast.hue, recast.chroma, lstar_from_y(y))
364    }
365}