Skip to main content

material_colors/hct/
solver.rs

1use super::{Cam16, ViewingConditions};
2#[cfg(all(not(feature = "std"), feature = "libm"))]
3#[allow(unused_imports)]
4use crate::utils::no_std::FloatExt;
5use crate::{
6    color::{y_from_lstar, Argb, LinearRgb},
7    utils::math::{matrix_multiply, sanitize_degrees_double, signum},
8};
9use core::f64::consts::PI;
10
11/// A struct that solves the HCT equation.
12const SCALED_DISCOUNT_FROM_LINRGB: [[f64; 3]; 3] = [
13    [
14        0.001200833568784504,
15        0.002389694492170889,
16        0.0002795742885861124,
17    ],
18    [
19        0.0005891086651375999,
20        0.0029785502573438758,
21        0.0003270666104008398,
22    ],
23    [
24        0.00010146692491640572,
25        0.0005364214359186694,
26        0.0032979401770712076,
27    ],
28];
29
30const LINRGB_FROM_SCALED_DISCOUNT: [[f64; 3]; 3] = [
31    [1373.2198709594231, -1100.4251190754821, -7.278681089101213],
32    [-271.815969077903, 559.6580465940733, -32.46047482791194],
33    [1.9622899599665666, -57.173814538844006, 308.7233197812385],
34];
35
36const Y_FROM_LINRGB: [f64; 3] = [0.2126, 0.7152, 0.0722];
37
38const CRITICAL_PLANES: [f64; 255] = [
39    0.015176349177441876,
40    0.045529047532325624,
41    0.07588174588720938,
42    0.10623444424209313,
43    0.13658714259697685,
44    0.16693984095186062,
45    0.19729253930674434,
46    0.2276452376616281,
47    0.2579979360165119,
48    0.28835063437139563,
49    0.3188300904430532,
50    0.350925934958123,
51    0.3848314933096426,
52    0.42057480301049466,
53    0.458183274052838,
54    0.4976837250274023,
55    0.5391024159806381,
56    0.5824650784040898,
57    0.6277969426914107,
58    0.6751227633498623,
59    0.7244668422128921,
60    0.775853049866786,
61    0.829304845476233,
62    0.8848452951698498,
63    0.942497089126609,
64    1.0022825574869039,
65    1.0642236851973577,
66    1.1283421258858297,
67    1.1946592148522128,
68    1.2631959812511864,
69    1.3339731595349034,
70    1.407011200216447,
71    1.4823302800086415,
72    1.5599503113873272,
73    1.6398909516233677,
74    1.7221716113234105,
75    1.8068114625156377,
76    1.8938294463134073,
77    1.9832442801866852,
78    2.075074464868551,
79    2.1693382909216234,
80    2.2660538449872063,
81    2.36523901573795,
82    2.4669114995532007,
83    2.5710888059345764,
84    2.6777882626779785,
85    2.7870270208169257,
86    2.898822059350997,
87    3.0131901897720907,
88    3.1301480604002863,
89    3.2497121605402226,
90    3.3718988244681087,
91    3.4967242352587946,
92    3.624204428461639,
93    3.754355295633311,
94    3.887192587735158,
95    4.022731918402185,
96    4.160988767090289,
97    4.301978482107941,
98    4.445716283538092,
99    4.592217266055746,
100    4.741496401646282,
101    4.893568542229298,
102    5.048448422192488,
103    5.20615066083972,
104    5.3666897647573375,
105    5.5300801301023865,
106    5.696336044816294,
107    5.865471690767354,
108    6.037501145825082,
109    6.212438385869475,
110    6.390297286737924,
111    6.571091626112461,
112    6.7548350853498045,
113    6.941541251256611,
114    7.131223617812143,
115    7.323895587840543,
116    7.5195704746346665,
117    7.7182615035334345,
118    7.919981813454504,
119    8.124744458384042,
120    8.332562408825165,
121    8.543448553206703,
122    8.757415699253682,
123    8.974476575321063,
124    9.194643831691977,
125    9.417930041841839,
126    9.644347703669503,
127    9.873909240696694,
128    10.106627003236781,
129    10.342513269534024,
130    10.58158024687427,
131    10.8238400726681,
132    11.069304815507364,
133    11.317986476196008,
134    11.569896988756009,
135    11.825048221409341,
136    12.083451977536606,
137    12.345119996613247,
138    12.610063955123938,
139    12.878295467455942,
140    13.149826086772048,
141    13.42466730586372,
142    13.702830557985108,
143    13.984327217668513,
144    14.269168601521828,
145    14.55736596900856,
146    14.848930523210871,
147    15.143873411576273,
148    15.44220572664832,
149    15.743938506781891,
150    16.04908273684337,
151    16.35764934889634,
152    16.66964922287304,
153    16.985093187232053,
154    17.30399201960269,
155    17.62635644741625,
156    17.95219714852476,
157    18.281524751807332,
158    18.614349837764564,
159    18.95068293910138,
160    19.290534541298456,
161    19.633915083172692,
162    19.98083495742689,
163    20.331304511189067,
164    20.685334046541502,
165    21.042933821039977,
166    21.404114048223256,
167    21.76888489811322,
168    22.137256497705877,
169    22.50923893145328,
170    22.884842241736916,
171    23.264076429332462,
172    23.6469514538663,
173    24.033477234264016,
174    24.42366364919083,
175    24.817520537484558,
176    25.21505769858089,
177    25.61628489293138,
178    26.021211842414342,
179    26.429848230738664,
180    26.842203703840827,
181    27.258287870275353,
182    27.678110301598522,
183    28.10168053274597,
184    28.529008062403893,
185    28.96010235337422,
186    29.39497283293396,
187    29.83362889318845,
188    30.276079891419332,
189    30.722335150426627,
190    31.172403958865512,
191    31.62629557157785,
192    32.08401920991837,
193    32.54558406207592,
194    33.010999283389665,
195    33.4802739966603,
196    33.953417292456834,
197    34.430438229418264,
198    34.911345834551085,
199    35.39614910352207,
200    35.88485700094671,
201    36.37747846067349,
202    36.87402238606382,
203    37.37449765026789,
204    37.87891309649659,
205    38.38727753828926,
206    38.89959975977785,
207    39.41588851594697,
208    39.93615253289054,
209    40.460400508064545,
210    40.98864111053629,
211    41.520882981230194,
212    42.05713473317016,
213    42.597404951718396,
214    43.141702194811224,
215    43.6900349931913,
216    44.24241185063697,
217    44.798841244188324,
218    45.35933162437017,
219    45.92389141541209,
220    46.49252901546552,
221    47.065252796817916,
222    47.64207110610409,
223    48.22299226451468,
224    48.808024568002054,
225    49.3971762874833,
226    49.9904556690408,
227    50.587870934119984,
228    51.189430279724725,
229    51.79514187861014,
230    52.40501387947288,
231    53.0190544071392,
232    53.637271562750364,
233    54.259673423945976,
234    54.88626804504493,
235    55.517063457223934,
236    56.15206766869424,
237    56.79128866487574,
238    57.43473440856916,
239    58.08241284012621,
240    58.734331877617365,
241    59.39049941699807,
242    60.05092333227251,
243    60.715611475655585,
244    61.38457167773311,
245    62.057811747619894,
246    62.7353394731159,
247    63.417162620860914,
248    64.10328893648692,
249    64.79372614476921,
250    65.48848194977529,
251    66.18756403501224,
252    66.89098006357258,
253    67.59873767827808,
254    68.31084450182222,
255    69.02730813691093,
256    69.74813616640164,
257    70.47333615344107,
258    71.20291564160104,
259    71.93688215501312,
260    72.67524319850172,
261    73.41800625771542,
262    74.16517879925733,
263    74.9167682708136,
264    75.67278210128072,
265    76.43322770089146,
266    77.1981124613393,
267    77.96744375590167,
268    78.74122893956174,
269    79.51947534912904,
270    80.30219030335869,
271    81.08938110306934,
272    81.88105503125999,
273    82.67721935322541,
274    83.4778813166706,
275    84.28304815182372,
276    85.09272707154808,
277    85.90692527145302,
278    86.72564993000343,
279    87.54890820862819,
280    88.3767072518277,
281    89.2090541872801,
282    90.04595612594655,
283    90.88742016217518,
284    91.73345337380438,
285    92.58406282226491,
286    93.43925555268066,
287    94.29903859396902,
288    95.16341895893969,
289    96.03240364439274,
290    96.9059996312159,
291    97.78421388448044,
292    98.6670533535366,
293    99.55452497210776,
294];
295
296pub struct HctSolver;
297
298impl HctSolver {
299    /// Sanitizes a small enough angle in radians.
300    ///
301    /// - `angle`: An angle in radians; must not deviate too much from 0.
302    ///
303    /// Returns A coterminal angle between 0 and 2pi.
304    fn sanitize_radians(angle: f64) -> f64 {
305        PI.mul_add(8.0, angle) % (PI * 2.0)
306    }
307
308    /// Delinearizes an Rgb component, returning a floating-point
309    /// number.
310    ///
311    /// 0.0 <= `rgb_component` <= 100.0 represents linear R/G/B channel.
312    /// 0.0 <= output <= 255.0, color channel converted to regular Rgb
313    /// space.
314    fn true_delinearized(rgb_component: f64) -> f64 {
315        let normalized = rgb_component / 100.0;
316        let delinearized = if normalized <= 0.0031308 {
317            normalized * 12.92
318        } else {
319            1.055f64.mul_add(normalized.powf(1.0 / 2.4), -0.055)
320        };
321
322        delinearized * 255.0
323    }
324
325    fn chromatic_adaptation(component: f64) -> f64 {
326        let af = component.abs().powf(0.42);
327        signum(component) * 400.0 * af / (af + 27.13)
328    }
329
330    /// Returns the hue of `linrgb`, a linear Rgb color, in CAM16, in
331    /// radians.
332    fn hue_of(linrgb: [f64; 3]) -> f64 {
333        let scaled_discount = matrix_multiply(linrgb, SCALED_DISCOUNT_FROM_LINRGB);
334
335        let r_a = Self::chromatic_adaptation(scaled_discount[0]);
336        let g_a = Self::chromatic_adaptation(scaled_discount[1]);
337        let b_a = Self::chromatic_adaptation(scaled_discount[2]);
338
339        // redness-greenness
340        let a = (11.0f64.mul_add(r_a, -12.0 * g_a) + b_a) / 11.0;
341
342        // yellowness-blueness
343        let b = 2.0f64.mul_add(-b_a, r_a + g_a) / 9.0;
344
345        b.atan2(a)
346    }
347
348    fn are_in_cyclic_order(a: f64, b: f64, c: f64) -> bool {
349        let delta_ab = Self::sanitize_radians(b - a);
350        let delta_ac = Self::sanitize_radians(c - a);
351
352        delta_ab < delta_ac
353    }
354
355    /// Solves the lerp equation.
356    ///
357    /// Returns a number t such that lerp(`source`, `target`, t) =
358    /// `mid`.
359    fn intercept(source: f64, mid: f64, target: f64) -> f64 {
360        (mid - source) / (target - source)
361    }
362
363    fn lerp_point(source: [f64; 3], t: f64, target: [f64; 3]) -> [f64; 3] {
364        [
365            (target[0] - source[0]).mul_add(t, source[0]),
366            (target[1] - source[1]).mul_add(t, source[1]),
367            (target[2] - source[2]).mul_add(t, source[2]),
368        ]
369    }
370
371    /// i32ersects a segment with a plane.
372    ///
373    /// Returns the intersection point of:
374    /// - the segment with `source` and `target` as its endpoints, and
375    /// - the plane
376    ///   ... R = `coordinate` if `axis` == 0
377    ///   ... G = `coordinate` if `axis` == 1
378    ///   ... B = `coordinate` if `axis` == 2
379    fn set_coordinate(
380        source: [f64; 3],
381        coordinate: f64,
382        target: [f64; 3],
383        axis: usize,
384    ) -> [f64; 3] {
385        let t = Self::intercept(source[axis], coordinate, target[axis]);
386
387        Self::lerp_point(source, t, target)
388    }
389
390    fn is_bounded(x: f64) -> bool {
391        (0.0..=100.0).contains(&x)
392    }
393
394    /// Returns the nth possible vertex of the polygonal intersection.
395    ///
396    /// Given a plane Y = `y` and an zero-based index `n` such that 0
397    /// <= `n` <= 11, returns the nth possible vertex of the polygonal
398    /// intersection of the plane and the Rgb cube, in linear Rgb
399    /// coordinates, if it exists.
400    /// If this possible vertex lies outside of the cube, `[-1.0, -1.0,
401    /// -1.0]` is returned.
402    fn nth_vertex(y: f64, n: i32) -> [f64; 3] {
403        let k_r = Y_FROM_LINRGB[0];
404        let k_g = Y_FROM_LINRGB[1];
405        let k_b = Y_FROM_LINRGB[2];
406
407        let coord_a = if n % 4 <= 1 { 0.0 } else { 100.0 };
408        let coord_b = if n % 2 == 0 { 0.0 } else { 100.0 };
409
410        if n < 4 {
411            let g: f64 = coord_a;
412            let b: f64 = coord_b;
413            let r = b.mul_add(-k_b, g.mul_add(-k_g, y)) / k_r;
414
415            if Self::is_bounded(r) {
416                [r, g, b]
417            } else {
418                [-1.0; 3]
419            }
420        } else if n < 8 {
421            let b = coord_a;
422            let r = coord_b;
423            let g = b.mul_add(-k_b, r.mul_add(-k_r, y)) / k_g;
424
425            if Self::is_bounded(g) {
426                [r, g, b]
427            } else {
428                [-1.0; 3]
429            }
430        } else {
431            let r = coord_a;
432            let g = coord_b;
433            let b = g.mul_add(-k_g, r.mul_add(-k_r, y)) / k_b;
434
435            if Self::is_bounded(b) {
436                [r, g, b]
437            } else {
438                [-1.0; 3]
439            }
440        }
441    }
442
443    /// Finds the segment containing the desired color.
444    ///
445    /// Given a plane Y = `y` and a desired `target_hue`, returns the
446    /// segment containing the desired color, represented as an array of
447    /// its two endpoints.
448    fn bisect_to_segment(y: f64, target_hue: f64) -> [[f64; 3]; 2] {
449        let mut left = [-1.0; 3];
450        let mut right = left;
451        let mut left_hue = 0.0;
452        let mut right_hue = 0.0;
453        let mut initialized = false;
454        let mut uncut = true;
455
456        for n in 0..12 {
457            let mid = Self::nth_vertex(y, n);
458
459            if mid[0] < 0.0 {
460                continue;
461            }
462
463            let mid_hue = Self::hue_of(mid);
464
465            if !initialized {
466                left = mid;
467                right = mid;
468                left_hue = mid_hue;
469                right_hue = mid_hue;
470                initialized = true;
471                continue;
472            }
473
474            if uncut || Self::are_in_cyclic_order(left_hue, mid_hue, right_hue) {
475                uncut = false;
476
477                if Self::are_in_cyclic_order(left_hue, target_hue, mid_hue) {
478                    right = mid;
479                    right_hue = mid_hue;
480                } else {
481                    left = mid;
482                    left_hue = mid_hue;
483                }
484            }
485        }
486
487        [left, right]
488    }
489
490    fn mid_point(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
491        [
492            (a[0] + b[0]) / 2.0,
493            (a[1] + b[1]) / 2.0,
494            (a[2] + b[2]) / 2.0,
495        ]
496    }
497
498    fn critical_plane_below(x: f64) -> i16 {
499        (x - 0.5).floor() as i16
500    }
501
502    fn critical_plane_above(x: f64) -> i16 {
503        (x - 0.5).ceil() as i16
504    }
505
506    /// Finds a color with the given Y and hue on the boundary of the
507    /// cube.
508    ///
509    /// Returns the color with the desired Y value `y` and hue
510    /// `target_hue`, in linear Rgb coordinates.
511    fn bisect_to_limit(y: f64, target_hue: f64) -> [f64; 3] {
512        let segment = Self::bisect_to_segment(y, target_hue);
513        let mut left = segment[0];
514        let mut left_hue = Self::hue_of(left);
515        let mut right = segment[1];
516
517        for axis in 0..3 {
518            if (left[axis] - right[axis]).abs() > f64::EPSILON {
519                let [mut l_plane, mut r_plane] = if left[axis] < right[axis] {
520                    [
521                        Self::critical_plane_below(Self::true_delinearized(left[axis])),
522                        Self::critical_plane_above(Self::true_delinearized(right[axis])),
523                    ]
524                } else {
525                    [
526                        Self::critical_plane_above(Self::true_delinearized(left[axis])),
527                        Self::critical_plane_below(Self::true_delinearized(right[axis])),
528                    ]
529                };
530
531                for _ in 0..8 {
532                    if (r_plane - l_plane).abs() <= 1 {
533                        break;
534                    }
535
536                    let m_plane = ((f64::from(l_plane) + f64::from(r_plane)) / 2.0).floor() as i16;
537                    let mid_plane_coordinate = CRITICAL_PLANES[m_plane as usize];
538                    let mid = Self::set_coordinate(left, mid_plane_coordinate, right, axis);
539                    let mid_hue = Self::hue_of(mid);
540
541                    if Self::are_in_cyclic_order(left_hue, target_hue, mid_hue) {
542                        right = mid;
543                        r_plane = m_plane;
544                    } else {
545                        left = mid;
546                        left_hue = mid_hue;
547                        l_plane = m_plane;
548                    }
549                }
550            }
551        }
552
553        Self::mid_point(left, right)
554    }
555
556    fn inverse_chromatic_adaptation(adapted: f64) -> f64 {
557        let adapted_abs = adapted.abs();
558        let base = (27.13 * adapted_abs / (400.0 - adapted_abs)).max(0.0);
559
560        signum(adapted) * base.powf(1.0 / 0.42)
561    }
562
563    /// Finds a color with the given hue, chroma, and Y.
564    ///
565    /// Returns a color with the desired `hue_radians`, `chroma`, and
566    /// `y` as a hexadecimal integer, if found; and returns 0 otherwise.
567    fn find_result_by_j(hue_radians: f64, chroma: f64, y: f64) -> Argb {
568        // Initial estimate of j.
569        let mut j = y.sqrt() * 11.0;
570        // ===========================================================
571        // Operations inlined from Cam16 to avoid repeated calculation
572        // ===========================================================
573        let viewing_conditions = ViewingConditions::standard();
574        let t_inner_coeff =
575            1.0 / (1.64 - 0.29f64.powf(viewing_conditions.background_ytowhite_point_y)).powf(0.73);
576        let e_hue = 0.25 * ((hue_radians + 2.0).cos() + 3.8);
577        let p1 = e_hue * (50000.0 / 13.0) * viewing_conditions.n_c * viewing_conditions.ncb;
578        let (h_sin, h_cos) = (hue_radians.sin(), hue_radians.cos());
579
580        for iteration_round in 0..5 {
581            // ===========================================================
582            // Operations inlined from Cam16 to avoid repeated calculation
583            // ===========================================================
584            let j_normalized = j / 100.0;
585            let alpha = if chroma == 0.0 || j == 0.0 {
586                0.0
587            } else {
588                chroma / j_normalized.sqrt()
589            };
590
591            let t = (alpha * t_inner_coeff).powf(1.0 / 0.9);
592            let ac = viewing_conditions.aw
593                * j_normalized.powf(1.0 / viewing_conditions.c / viewing_conditions.z);
594            let p2 = ac / viewing_conditions.nbb;
595            let gamma = 23.0 * (p2 + 0.305) * t
596                / (108.0 * t).mul_add(h_sin, 23.0f64.mul_add(p1, 11.0 * t * h_cos));
597            let a = gamma * h_cos;
598            let b = gamma * h_sin;
599            let (r_a, g_a, b_a) = (
600                288.0f64.mul_add(b, 460.0f64.mul_add(p2, 451.0 * a)) / 1403.0,
601                261.0f64.mul_add(-b, 460.0f64.mul_add(p2, -891.0 * a)) / 1403.0,
602                6300.0f64.mul_add(-b, 460.0f64.mul_add(p2, -220.0 * a)) / 1403.0,
603            );
604
605            let r_cscaled = Self::inverse_chromatic_adaptation(r_a);
606            let g_cscaled = Self::inverse_chromatic_adaptation(g_a);
607            let b_cscaled = Self::inverse_chromatic_adaptation(b_a);
608            let [red, green, blue] = matrix_multiply(
609                [r_cscaled, g_cscaled, b_cscaled],
610                LINRGB_FROM_SCALED_DISCOUNT,
611            );
612
613            let linrgb = LinearRgb { red, green, blue };
614            // ===========================================================
615            // Operations inlined from Cam16 to avoid repeated calculation
616            // ===========================================================
617            if linrgb.red < 0.0 || linrgb.green < 0.0 || linrgb.blue < 0.0 {
618                return Argb::default();
619            }
620
621            let [k_r, k_g, k_b] = Y_FROM_LINRGB;
622            let fnj = k_b.mul_add(linrgb.blue, k_r.mul_add(linrgb.red, k_g * linrgb.green));
623            if fnj <= 0.0 {
624                return Argb::default();
625            }
626
627            if iteration_round == 4 || (fnj - y).abs() < 0.002 {
628                if linrgb.red > 100.01 || linrgb.green > 100.01 || linrgb.blue > 100.01 {
629                    return Argb::default();
630                }
631
632                return linrgb.into();
633            }
634
635            // Iterates with Newton method,
636            // Using 2 * fn(j) / j as the approximation of fn'(j)
637            j = j - (fnj - y) * j / (2.0 * fnj);
638        }
639
640        Argb::default()
641    }
642
643    /// Finds an sRgb color with the given hue, chroma, and L*, if
644    /// possible.
645    ///
646    /// Returns a hexadecimal representing a sRgb color with its hue,
647    /// chroma, and L* sufficiently close to `hue_degrees`, `chroma`, and
648    /// `lstar`, respectively. If it is impossible to satisfy all three
649    /// constraints, the hue and L* will be sufficiently close, and the
650    /// chroma will be maximized.
651    pub fn solve_to_argb(hue_degrees: f64, chroma: f64, lstar: f64) -> Argb {
652        if chroma < 0.0001 || !(0.0001..=99.9999).contains(&lstar) {
653            return Argb::from_lstar(lstar);
654        }
655
656        let hue_degrees = sanitize_degrees_double(hue_degrees);
657        let hue_radians = hue_degrees.to_radians();
658
659        let y = y_from_lstar(lstar);
660
661        let exact_answer = Self::find_result_by_j(hue_radians, chroma, y);
662
663        if exact_answer != Argb::default() {
664            return exact_answer;
665        }
666
667        let [red, green, blue] = Self::bisect_to_limit(y, hue_radians);
668
669        let linrgb = LinearRgb { red, green, blue };
670
671        linrgb.into()
672    }
673
674    /// Finds a CAM16 object with the given hue, chroma, and L*, if
675    /// possible.
676    ///
677    /// Returns a CAM16 object representing a sRgb color with its hue,
678    /// chroma, and L* sufficiently close to `hue_degrees`, `chroma`, and
679    /// `lstar`, respectively. If it is impossible to satisfy all three
680    /// constraints, the hue and L* will be sufficiently close, and the
681    /// chroma will be maximized.
682    pub fn solve_to_cam(hue_degrees: f64, chroma: f64, lstar: f64) -> Cam16 {
683        Cam16::from(Self::solve_to_argb(hue_degrees, chroma, lstar))
684    }
685}