Skip to main content

material_colors/hct/
mod.rs

1use crate::color::{lstar_from_y, Argb};
2#[cfg(all(not(feature = "std"), feature = "libm"))]
3#[allow(unused_imports)]
4use crate::utils::no_std::FloatExt;
5use core::{
6    cmp::Ordering,
7    fmt,
8    hash::{Hash, Hasher},
9};
10#[cfg(feature = "serde")]
11use serde::Serialize;
12pub use {cam16::Cam16, solver::HctSolver, viewing_conditions::ViewingConditions};
13
14pub mod cam16;
15pub mod solver;
16pub mod viewing_conditions;
17
18#[derive(Clone, Copy, Debug, PartialOrd)]
19#[cfg_attr(feature = "serde", derive(Serialize))]
20pub struct Hct {
21    _hue: f64,
22    _chroma: f64,
23    _tone: f64,
24    _argb: Argb,
25}
26
27impl Hct {
28    /// A number, in degrees, representing ex. red, orange, yellow, etc.
29    /// Ranges from 0 <= `hue` < 360
30    ///
31    /// 0 <= `new_hue` < 360; invalid values are corrected.
32    /// After setting hue, the color is mapped from HCT to the more
33    /// limited sRgb gamut for display. This will change its Argb/integer
34    /// representation. If the HCT color is outside of the sRgb gamut, chroma
35    /// will decrease until it is inside the gamut.
36    pub const fn get_hue(&self) -> f64 {
37        self._hue
38    }
39
40    /// A number, in degrees, representing ex. red, orange, yellow, etc.
41    /// Ranges from 0 <= `hue` < 360
42    ///
43    /// 0 <= `new_hue` < 360; invalid values are corrected.
44    /// After setting hue, the color is mapped from HCT to the more
45    /// limited sRgb gamut for display. This will change its Argb/integer
46    /// representation. If the HCT color is outside of the sRgb gamut, chroma
47    /// will decrease until it is inside the gamut.
48    pub fn set_hue(&mut self, value: f64) {
49        self._argb = HctSolver::solve_to_argb(value, self.get_chroma(), self.get_tone());
50
51        let cam16 = Cam16::from(self._argb);
52
53        self._hue = cam16.hue;
54        self._chroma = cam16.chroma;
55        self._tone = self._argb.as_lstar();
56    }
57
58    /// 0 <= `new_chroma` <= ?
59    /// After setting chroma, the color is mapped from HCT to the more
60    /// limited sRgb gamut for display. This will change its Argb/integer
61    /// representation. If the HCT color is outside of the sRgb gamut, chroma
62    /// will decrease until it is inside the gamut.
63    pub const fn get_chroma(&self) -> f64 {
64        self._chroma
65    }
66
67    /// 0 <= `new_chroma` <= ?
68    /// After setting chroma, the color is mapped from HCT to the more
69    /// limited sRgb gamut for display. This will change its Argb/integer
70    /// representation. If the HCT color is outside of the sRgb gamut, chroma
71    /// will decrease until it is inside the gamut.
72    pub fn set_chroma(&mut self, value: f64) {
73        self._argb = HctSolver::solve_to_argb(self.get_hue(), value, self.get_tone());
74
75        let cam16 = Cam16::from(self._argb);
76
77        self._hue = cam16.hue;
78        self._chroma = cam16.chroma;
79        self._tone = self._argb.as_lstar();
80    }
81
82    /// Lightness. Ranges from 0 to 100.
83    ///
84    /// 0 <= `new_tone` <= 100; invalid values are corrected.
85    /// After setting tone, the color is mapped from HCT to the more
86    /// limited sRgb gamut for display. This will change its Argb/integer
87    /// representation. If the HCT color is outside of the sRgb gamut, chroma
88    /// will decrease until it is inside the gamut.
89    pub const fn get_tone(&self) -> f64 {
90        self._tone
91    }
92
93    /// Lightness. Ranges from 0 to 100.
94    ///
95    /// 0 <= `new_tone` <= 100; invalid values are corrected.
96    /// After setting tone, the color is mapped from HCT to the more
97    /// limited sRgb gamut for display. This will change its Argb/integer
98    /// representation. If the HCT color is outside of the sRgb gamut, chroma
99    /// will decrease until it is inside the gamut.
100    pub fn set_tone(&mut self, value: f64) {
101        self._argb = HctSolver::solve_to_argb(self.get_hue(), self.get_chroma(), value);
102
103        let cam16 = Cam16::from(self._argb);
104
105        self._hue = cam16.hue;
106        self._chroma = cam16.chroma;
107        self._tone = self._argb.as_lstar();
108    }
109
110    pub fn new(argb: Argb) -> Self {
111        let cam16 = Cam16::from(argb);
112
113        Self {
114            _hue: cam16.hue,
115            _chroma: cam16.chroma,
116            _tone: argb.as_lstar(),
117            _argb: argb,
118        }
119    }
120
121    /// 0 <= `hue` < 360; invalid values are corrected.
122    /// 0 <= `chroma` <= ?; Informally, colorfulness. The color returned may be
123    ///    lower than the requested chroma. Chroma has a different maximum for any
124    ///    given hue and tone.
125    /// 0 <= `tone` <= 100; informally, lightness. Invalid values are corrected.
126    pub fn from(hue: f64, chroma: f64, tone: f64) -> Self {
127        let argb = HctSolver::solve_to_argb(hue, chroma, tone);
128
129        Self::new(argb)
130    }
131
132    /// Translate a color into different [`ViewingConditions`].
133    ///
134    /// Colors change appearance. They look different with lights on versus off,
135    /// the same color, as in hex code, on white looks different when on black.
136    /// This is called color relativity, most famously explicated by Josef Albers
137    /// in Interaction of Color.
138    ///
139    /// In color science, color appearance models can account for this and
140    /// calculate the appearance of a color in different settings. HCT is based on
141    /// CAM16, a color appearance model, and uses it to make these calculations.
142    ///
143    /// See [`ViewingConditions`] for parameters affecting color appearance.
144    #[must_use]
145    pub fn in_viewing_conditions(self, vc: &ViewingConditions) -> Self {
146        // 1. Use CAM16 to find Xyz coordinates of color in specified VC.
147        let cam16 = Cam16::from(Argb::from(self));
148        let viewed_in_vc = cam16.xyz_in_viewing_conditions(vc);
149
150        // 2. Create CAM16 of those Xyz coordinates in default VC.
151        let recast_in_vc = Cam16::from_xyz_in_viewing_conditions(
152            viewed_in_vc.x,
153            viewed_in_vc.y,
154            viewed_in_vc.z,
155            &ViewingConditions::standard(),
156        );
157
158        // 3. Create HCT from:
159        // - CAM16 using default VC with Xyz coordinates in specified VC.
160        // - L* converted from Y in Xyz coordinates in specified VC.
161        Self::from(
162            recast_in_vc.hue,
163            recast_in_vc.chroma,
164            lstar_from_y(viewed_in_vc.y),
165        )
166    }
167}
168
169impl fmt::Display for Hct {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        write!(
172            f,
173            "H{} C{} T{}",
174            self.get_hue().round(),
175            self.get_chroma().round(),
176            self.get_tone().round()
177        )
178    }
179}
180
181impl Ord for Hct {
182    fn cmp(&self, other: &Self) -> Ordering {
183        self.partial_cmp(other).unwrap()
184    }
185}
186
187impl PartialEq for Hct {
188    fn eq(&self, other: &Self) -> bool {
189        self._argb == other._argb
190    }
191}
192
193impl Eq for Hct {}
194
195impl Hash for Hct {
196    fn hash<H: Hasher>(&self, state: &mut H) {
197        self._hue.to_bits().hash(state);
198        self._chroma.to_bits().hash(state);
199        self._tone.to_bits().hash(state);
200        self._argb.hash(state);
201    }
202}
203
204impl From<Argb> for Hct {
205    fn from(value: Argb) -> Self {
206        Self::new(value)
207    }
208}
209
210impl From<Hct> for Argb {
211    fn from(value: Hct) -> Self {
212        value._argb
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::{Cam16, Hct, ViewingConditions};
219    use crate::color::{y_from_lstar, Argb};
220    use ahash::AHasher;
221    #[cfg(not(feature = "std"))]
222    use alloc::format;
223    use core::hash::{Hash, Hasher};
224    use float_cmp::{approx_eq, assert_approx_eq};
225    #[cfg(feature = "std")]
226    use std::format;
227
228    const BLACK: Argb = Argb::from_u32(0xFF000000);
229    const WHITE: Argb = Argb::from_u32(0xFFFFFFFF);
230    const RED: Argb = Argb::from_u32(0xFFFF0000);
231    const GREEN: Argb = Argb::from_u32(0xFF00FF00);
232    const BLUE: Argb = Argb::from_u32(0xFF0000FF);
233    const MIDGRAY: Argb = Argb::from_u32(0xFF777777);
234
235    fn hash_value<T: Hash>(value: T) -> u64 {
236        let mut hasher = AHasher::default();
237
238        value.hash(&mut hasher);
239
240        hasher.finish()
241    }
242
243    const fn color_is_on_boundary(argb: Argb) -> bool {
244        argb.red == 0
245            || argb.red == 255
246            || argb.green == 0
247            || argb.green == 255
248            || argb.blue == 0
249            || argb.blue == 255
250    }
251
252    #[test]
253    fn test_hash_code() {
254        let a: Hct = Argb::from_u32(123).into();
255        let b: Hct = Argb::from_u32(123).into();
256
257        assert_eq!(a, b);
258        assert_eq!(hash_value(a), hash_value(b));
259    }
260
261    #[test]
262    fn test_conversions_are_reflexive() {
263        let cam = Cam16::from(RED);
264        let color = cam.viewed(&ViewingConditions::standard());
265
266        assert_eq!(color, RED);
267    }
268
269    #[test]
270    fn test_ymidgray() {
271        assert_approx_eq!(f64, 18.418, y_from_lstar(50.0), epsilon = 0.001);
272    }
273
274    #[test]
275    fn test_yblack() {
276        assert_approx_eq!(f64, 0.0, y_from_lstar(0.0), epsilon = 0.001);
277    }
278
279    #[test]
280    fn test_ywhite() {
281        assert_approx_eq!(f64, 100.0, y_from_lstar(100.0), epsilon = 0.001);
282    }
283
284    #[test]
285    fn test_cam_red() {
286        let cam = Cam16::from(RED);
287
288        assert_approx_eq!(f64, 46.445, cam.j, epsilon = 0.001);
289        assert_approx_eq!(f64, 113.357, cam.chroma, epsilon = 0.001);
290        assert_approx_eq!(f64, 27.408, cam.hue, epsilon = 0.001);
291        assert_approx_eq!(f64, 89.494, cam.m, epsilon = 0.001);
292        assert_approx_eq!(f64, 91.889, cam.s, epsilon = 0.001);
293        assert_approx_eq!(f64, 105.988, cam.q, epsilon = 0.001);
294    }
295
296    #[test]
297    fn test_cam_green() {
298        let cam = Cam16::from(GREEN);
299
300        assert_approx_eq!(f64, 79.331, cam.j, epsilon = 0.001);
301        assert_approx_eq!(f64, 108.410, cam.chroma, epsilon = 0.001);
302        assert_approx_eq!(f64, 142.139, cam.hue, epsilon = 0.001);
303        assert_approx_eq!(f64, 85.587, cam.m, epsilon = 0.001);
304        assert_approx_eq!(f64, 78.604, cam.s, epsilon = 0.001);
305        assert_approx_eq!(f64, 138.520, cam.q, epsilon = 0.001);
306    }
307
308    #[test]
309    fn test_cam_blue() {
310        let cam = Cam16::from(BLUE);
311
312        assert_approx_eq!(f64, 25.465, cam.j, epsilon = 0.001);
313        assert_approx_eq!(f64, 87.230, cam.chroma, epsilon = 0.001);
314        assert_approx_eq!(f64, 282.788, cam.hue, epsilon = 0.001);
315        assert_approx_eq!(f64, 68.867, cam.m, epsilon = 0.001);
316        assert_approx_eq!(f64, 93.674, cam.s, epsilon = 0.001);
317        assert_approx_eq!(f64, 78.481, cam.q, epsilon = 0.001);
318    }
319
320    #[test]
321    fn test_cam_black() {
322        let cam = Cam16::from(BLACK);
323
324        assert_approx_eq!(f64, 0.0, cam.j, epsilon = 0.001);
325        assert_approx_eq!(f64, 0.0, cam.chroma, epsilon = 0.001);
326        assert_approx_eq!(f64, 0.0, cam.hue, epsilon = 0.001);
327        assert_approx_eq!(f64, 0.0, cam.m, epsilon = 0.001);
328        assert_approx_eq!(f64, 0.0, cam.s, epsilon = 0.001);
329        assert_approx_eq!(f64, 0.0, cam.q, epsilon = 0.001);
330    }
331
332    #[test]
333    fn test_cam_white() {
334        let cam = Cam16::from(WHITE);
335
336        assert_approx_eq!(f64, 100.0, cam.j, epsilon = 0.001);
337        assert_approx_eq!(f64, 2.869, cam.chroma, epsilon = 0.001);
338        assert_approx_eq!(f64, 209.492, cam.hue, epsilon = 0.001);
339        assert_approx_eq!(f64, 2.265, cam.m, epsilon = 0.001);
340        assert_approx_eq!(f64, 12.068, cam.s, epsilon = 0.001);
341        assert_approx_eq!(f64, 155.521, cam.q, epsilon = 0.001);
342    }
343
344    #[test]
345    fn test_camut_map_red() {
346        let color_to_test = RED;
347        let cam = Cam16::from(color_to_test);
348        let color = Hct::from(cam.hue, cam.chroma, color_to_test.as_lstar()).into();
349
350        assert_eq!(color_to_test, color);
351    }
352
353    #[test]
354    fn test_camut_map_green() {
355        let color_to_test = GREEN;
356        let cam = Cam16::from(color_to_test);
357        let color = Hct::from(cam.hue, cam.chroma, color_to_test.as_lstar()).into();
358
359        assert_eq!(color_to_test, color);
360    }
361
362    #[test]
363    fn test_camut_map_blue() {
364        let color_to_test = BLUE;
365        let cam = Cam16::from(color_to_test);
366        let color = Hct::from(cam.hue, cam.chroma, color_to_test.as_lstar()).into();
367
368        assert_eq!(color_to_test, color);
369    }
370
371    #[test]
372    fn test_camut_map_white() {
373        let color_to_test = WHITE;
374        let cam = Cam16::from(color_to_test);
375        let color = Hct::from(cam.hue, cam.chroma, color_to_test.as_lstar()).into();
376
377        assert_eq!(color_to_test, color);
378    }
379
380    #[test]
381    fn test_camut_map_midgray() {
382        let color_to_test = MIDGRAY;
383        let cam = Cam16::from(color_to_test);
384        let color = Hct::from(cam.hue, cam.chroma, color_to_test.as_lstar()).into();
385
386        assert_eq!(color_to_test, color);
387    }
388
389    #[test]
390    fn test_camut_map_black() {
391        let color_to_test = BLACK;
392        let cam = Cam16::from(color_to_test);
393        let color = Hct::from(cam.hue, cam.chroma, color_to_test.as_lstar()).into();
394
395        assert_eq!(color_to_test, color);
396    }
397
398    #[test]
399    fn test_hct_returns_sufficiently_close_color() {
400        for hue in (15..361).step_by(30) {
401            for chroma in (0..100).step_by(10) {
402                for tone in (20..80).step_by(10) {
403                    let hct_request_description = format!("H{hue} C{chroma} T{tone}");
404                    let hct_color = Hct::from(f64::from(hue), f64::from(chroma), f64::from(tone));
405
406                    if chroma > 0 {
407                        assert!(
408                            approx_eq!(f64, hct_color.get_hue(), f64::from(hue), epsilon = 4.0),
409                            "Hue should be close for {hct_request_description}"
410                        );
411                    }
412
413                    assert!(
414                        (0.0..(f64::from(chroma) + 2.5)).contains(&hct_color.get_chroma()),
415                        "Chroma should be close or less for {hct_request_description}"
416                    );
417
418                    if hct_color.get_chroma() < f64::from(chroma) - 2.5 {
419                        assert!(
420                            color_is_on_boundary(hct_color.into()),
421                            "HCT request for non-sRGB color should return a color on the boundary of the sRGB cube for {hct_request_description}, but got {} instead",
422                            Argb::from(hct_color).to_hex_with_pound()
423                        );
424                    }
425
426                    assert!(
427                        approx_eq!(f64, hct_color.get_tone(), f64::from(tone), epsilon = 0.5),
428                        "Tone should be close for {hct_request_description}"
429                    );
430                }
431            }
432        }
433    }
434
435    #[test]
436    fn test_cam16_to_xyz_without_array() {
437        let color_to_test = RED;
438        let cam = Cam16::from(color_to_test);
439        let xyz = cam.xyz_in_viewing_conditions(&ViewingConditions::s_rgb());
440
441        assert_approx_eq!(f64, xyz.x, 41.23, epsilon = 0.01);
442        assert_approx_eq!(f64, xyz.y, 21.26, epsilon = 0.01);
443        assert_approx_eq!(f64, xyz.z, 1.93, epsilon = 0.01);
444    }
445
446    #[test]
447    fn test_color_relativity_red_in_black() {
448        let color_to_test = RED;
449        let hct: Hct = color_to_test.into();
450
451        let result =
452            hct.in_viewing_conditions(&ViewingConditions::make(None, None, Some(0.0), None, None));
453
454        assert_eq!(Argb::from(result), Argb::from_u32(0xFF9F5C51));
455    }
456
457    #[test]
458    fn test_color_relativity_red_in_white() {
459        let color_to_test = RED;
460        let hct: Hct = color_to_test.into();
461
462        let result = hct.in_viewing_conditions(&ViewingConditions::make(
463            None,
464            None,
465            Some(100.0),
466            None,
467            None,
468        ));
469
470        assert_eq!(Argb::from(result), Argb::from_u32(0xFFFF5D48));
471    }
472
473    #[test]
474    fn test_color_relativity_green_in_black() {
475        let color_to_test = GREEN;
476        let hct: Hct = color_to_test.into();
477
478        let result =
479            hct.in_viewing_conditions(&ViewingConditions::make(None, None, Some(0.0), None, None));
480
481        assert_eq!(Argb::from(result), Argb::from_u32(0xFFACD69D));
482    }
483
484    #[test]
485    fn test_color_relativity_green_in_white() {
486        let color_to_test = GREEN;
487        let hct: Hct = color_to_test.into();
488
489        let result = hct.in_viewing_conditions(&ViewingConditions::make(
490            None,
491            None,
492            Some(100.0),
493            None,
494            None,
495        ));
496
497        assert_eq!(Argb::from(result), Argb::from_u32(0xFF8EFF77));
498    }
499
500    #[test]
501    fn test_color_relativity_blue_in_black() {
502        let color_to_test = BLUE;
503        let hct: Hct = color_to_test.into();
504
505        let result =
506            hct.in_viewing_conditions(&ViewingConditions::make(None, None, Some(0.0), None, None));
507
508        assert_eq!(Argb::from(result), Argb::from_u32(0xFF343654));
509    }
510
511    #[test]
512    fn test_color_relativity_blue_in_white() {
513        let color_to_test = BLUE;
514        let hct: Hct = color_to_test.into();
515
516        let result = hct.in_viewing_conditions(&ViewingConditions::make(
517            None,
518            None,
519            Some(100.0),
520            None,
521            None,
522        ));
523
524        assert_eq!(Argb::from(result), Argb::from_u32(0xFF3F49FF));
525    }
526
527    #[test]
528    fn test_color_relativity_white_in_black() {
529        let color_to_test = WHITE;
530        let hct: Hct = color_to_test.into();
531
532        let result =
533            hct.in_viewing_conditions(&ViewingConditions::make(None, None, Some(0.0), None, None));
534
535        assert_eq!(Argb::from(result), Argb::from_u32(0xFFFFFFFF));
536    }
537
538    #[test]
539    fn test_color_relativity_white_in_white() {
540        let color_to_test = WHITE;
541        let hct: Hct = color_to_test.into();
542
543        let result = hct.in_viewing_conditions(&ViewingConditions::make(
544            None,
545            None,
546            Some(100.0),
547            None,
548            None,
549        ));
550
551        assert_eq!(Argb::from(result), Argb::from_u32(0xFFFFFFFF));
552    }
553
554    #[test]
555    fn test_color_relativity_midgray_in_black() {
556        let color_to_test = MIDGRAY;
557        let hct: Hct = color_to_test.into();
558
559        let result =
560            hct.in_viewing_conditions(&ViewingConditions::make(None, None, Some(0.0), None, None));
561
562        assert_eq!(Argb::from(result), Argb::from_u32(0xFF605F5F));
563    }
564
565    #[test]
566    fn test_color_relativity_midgray_in_white() {
567        let color_to_test = MIDGRAY;
568        let hct: Hct = color_to_test.into();
569
570        let result = hct.in_viewing_conditions(&ViewingConditions::make(
571            None,
572            None,
573            Some(100.0),
574            None,
575            None,
576        ));
577
578        assert_eq!(Argb::from(result), Argb::from_u32(0xFF8E8E8E));
579    }
580
581    #[test]
582    fn test_color_relativity_black_in_black() {
583        let color_to_test = BLACK;
584        let hct: Hct = color_to_test.into();
585
586        let result =
587            hct.in_viewing_conditions(&ViewingConditions::make(None, None, Some(0.0), None, None));
588
589        assert_eq!(Argb::from(result), Argb::from_u32(0xFF000000));
590    }
591
592    #[test]
593    fn test_color_relativity_black_in_white() {
594        let color_to_test = BLACK;
595        let hct: Hct = color_to_test.into();
596
597        let result = hct.in_viewing_conditions(&ViewingConditions::make(
598            None,
599            None,
600            Some(100.0),
601            None,
602            None,
603        ));
604
605        assert_eq!(Argb::from(result), Argb::from_u32(0xFF000000));
606    }
607}