1use crate::Color;
16
17fn 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
30fn 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
41fn linearized(c: f64) -> f64 {
44 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 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
62fn 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
74fn 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
83struct 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]; 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 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 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
155fn cam16_hue_chroma(color: Color) -> (f64, f64) {
157 let vc = default_viewing_conditions();
158 let [x, y, z] = xyz_from_color(color);
159
160 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 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 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
197const 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
208fn gray(tone: f64) -> Color {
210 let y = y_from_lstar(tone);
211 let component = delinearized(y) as f32;
213 Color::rgb(component, component, component)
214}
215
216fn 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 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 j -= (fnj - y_target) * j / (2.0 * fnj);
276 }
277 None
278}
279
280#[derive(Clone, Copy, Debug, PartialEq)]
282pub struct Hct {
283 pub hue: f64,
285 pub chroma: f64,
287 pub tone: f64,
289}
290
291impl Hct {
292 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 pub fn solve(hue: f64, chroma: f64, tone: f64) -> Color {
302 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 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#[derive(Clone, Copy, Debug, PartialEq)]
334pub struct TonalPalette {
335 pub hue: f64,
337 pub chroma: f64,
339}
340
341impl TonalPalette {
342 pub fn new(hue: f64, chroma: f64) -> Self {
344 Self { hue, chroma }
345 }
346
347 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 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 let mid = Hct::from_color(Color::rgb8(0x80, 0x80, 0x80));
371 assert_close(mid.tone, 53.585, 0.2, "ton gris moyen");
372 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 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 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 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 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), ];
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 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), 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}