1mod colorimetry;
4mod illuminant;
5mod km;
6mod sigmoid;
7
8pub use colorimetry::{
9 illuminant_a, illuminant_d50, illuminant_d65, illuminant_e, spectrum_to_linear_rgb,
10 RgbTransform,
11};
12pub use illuminant::{LAMBDAS, LAMBDA_MAX, LAMBDA_MIN, LAMBDA_STEP, N_SPECTRAL};
13pub use km::{ks_mix, ks_mix_weighted, ks_to_reflectance, reflectance_to_ks, KsSpectrum};
14pub use sigmoid::{rgb_to_sigmoid, sigmoid_to_spectrum, SigmoidCoeffs};
15
16pub use neco_color::{
18 linear_to_srgb, linear_to_srgb_lut, srgb_to_linear, srgb_to_linear_lut, to_u8,
19};
20
21#[derive(Debug, Clone)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub enum PigmentError {
25 ConvergenceFailure {
27 residual: f64,
29 },
30}
31
32impl std::fmt::Display for PigmentError {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 PigmentError::ConvergenceFailure { residual } => {
36 write!(
37 f,
38 "Gauss-Newton optimization did not converge (residual: {residual:.6e})"
39 )
40 }
41 }
42 }
43}
44
45impl std::error::Error for PigmentError {}
46
47const BLACK_THRESHOLD: f32 = 1e-6;
48const WHITE_THRESHOLD: f32 = 1.0 - 1e-6;
49
50#[derive(Clone, Debug)]
52pub struct Pigment {
53 pub srgb: [f32; 3],
54 pub coeffs: SigmoidCoeffs,
55 pub ks: KsSpectrum,
56}
57
58impl Pigment {
59 pub fn from_srgb(r: f32, g: f32, b: f32) -> Result<Pigment, PigmentError> {
61 let max_ch = r.max(g).max(b);
62 let min_ch = r.min(g).min(b);
63
64 if max_ch < BLACK_THRESHOLD {
65 return Ok(Pigment {
66 srgb: [r, g, b],
67 coeffs: SigmoidCoeffs {
68 c0: 0.0,
69 c1: 0.0,
70 c2: -1e6,
71 },
72 ks: KsSpectrum {
73 ks: [km::KS_MAX; N_SPECTRAL],
74 },
75 });
76 }
77 if min_ch > WHITE_THRESHOLD {
78 return Ok(Pigment {
79 srgb: [r, g, b],
80 coeffs: SigmoidCoeffs {
81 c0: 0.0,
82 c1: 0.0,
83 c2: 1e6,
84 },
85 ks: KsSpectrum {
86 ks: [0.0; N_SPECTRAL],
87 },
88 });
89 }
90
91 let coeffs = rgb_to_sigmoid(r, g, b)?;
92 let refl = sigmoid_to_spectrum(&coeffs);
93 let ks = reflectance_to_ks(&refl);
94 Ok(Pigment {
95 srgb: [r, g, b],
96 coeffs,
97 ks,
98 })
99 }
100
101 pub fn spectrum(&self) -> [f32; N_SPECTRAL] {
103 sigmoid_to_spectrum(&self.coeffs)
104 }
105}
106
107pub fn rgb_to_ks(r: f32, g: f32, b: f32) -> Result<KsSpectrum, PigmentError> {
109 let max_ch = r.max(g).max(b);
110 let min_ch = r.min(g).min(b);
111
112 if max_ch < BLACK_THRESHOLD {
113 return Ok(KsSpectrum {
114 ks: [km::KS_MAX; N_SPECTRAL],
115 });
116 }
117 if min_ch > WHITE_THRESHOLD {
118 return Ok(KsSpectrum {
119 ks: [0.0; N_SPECTRAL],
120 });
121 }
122
123 let coeffs = rgb_to_sigmoid(r, g, b)?;
124 let refl = sigmoid_to_spectrum(&coeffs);
125 Ok(reflectance_to_ks(&refl))
126}
127
128pub fn ks_to_srgb(ks: &KsSpectrum, transform: &RgbTransform) -> [f32; 3] {
130 let refl = ks_to_reflectance(ks);
131 let linear = spectrum_to_linear_rgb(&refl, transform);
132 [
133 neco_color::linear_to_srgb_lut(linear[0]),
134 neco_color::linear_to_srgb_lut(linear[1]),
135 neco_color::linear_to_srgb_lut(linear[2]),
136 ]
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 fn srgb_to_xyz(r: f32, g: f32, b: f32) -> [f64; 3] {
147 let rl = neco_color::srgb_to_linear(r) as f64;
148 let gl = neco_color::srgb_to_linear(g) as f64;
149 let bl = neco_color::srgb_to_linear(b) as f64;
150 let x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl;
152 let y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl;
153 let z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl;
154 [x, y, z]
155 }
156
157 fn xyz_to_lab(xyz: [f64; 3]) -> [f64; 3] {
159 let xn = 0.95047;
161 let yn = 1.0;
162 let zn = 1.08883;
163
164 let f = |t: f64| -> f64 {
165 if t > (6.0 / 29.0_f64).powi(3) {
166 t.cbrt()
167 } else {
168 t / (3.0 * (6.0 / 29.0_f64).powi(2)) + 4.0 / 29.0
169 }
170 };
171
172 let fx = f(xyz[0] / xn);
173 let fy = f(xyz[1] / yn);
174 let fz = f(xyz[2] / zn);
175
176 let l = 116.0 * fy - 16.0;
177 let a = 500.0 * (fx - fy);
178 let b = 200.0 * (fy - fz);
179 [l, a, b]
180 }
181
182 fn delta_e_2000(lab1: [f64; 3], lab2: [f64; 3]) -> f64 {
184 let (l1, a1, b1) = (lab1[0], lab1[1], lab1[2]);
185 let (l2, a2, b2) = (lab2[0], lab2[1], lab2[2]);
186
187 let c1_star = (a1 * a1 + b1 * b1).sqrt();
188 let c2_star = (a2 * a2 + b2 * b2).sqrt();
189 let c_bar = (c1_star + c2_star) / 2.0;
190
191 let c_bar_7 = c_bar.powi(7);
192 let g = 0.5 * (1.0 - (c_bar_7 / (c_bar_7 + 25.0_f64.powi(7))).sqrt());
193
194 let a1p = a1 * (1.0 + g);
195 let a2p = a2 * (1.0 + g);
196
197 let c1p = (a1p * a1p + b1 * b1).sqrt();
198 let c2p = (a2p * a2p + b2 * b2).sqrt();
199
200 let h1p = b1.atan2(a1p).to_degrees().rem_euclid(360.0);
201 let h2p = b2.atan2(a2p).to_degrees().rem_euclid(360.0);
202
203 let dl = l2 - l1;
204 let dc = c2p - c1p;
205
206 let dh_deg = if c1p * c2p == 0.0 {
207 0.0
208 } else if (h2p - h1p).abs() <= 180.0 {
209 h2p - h1p
210 } else if h2p - h1p > 180.0 {
211 h2p - h1p - 360.0
212 } else {
213 h2p - h1p + 360.0
214 };
215 let dh = 2.0 * (c1p * c2p).sqrt() * (dh_deg.to_radians() / 2.0).sin();
216
217 let l_bar = (l1 + l2) / 2.0;
218 let c_bar_p = (c1p + c2p) / 2.0;
219
220 let h_bar_p = if c1p * c2p == 0.0 {
221 h1p + h2p
222 } else if (h1p - h2p).abs() <= 180.0 {
223 (h1p + h2p) / 2.0
224 } else if h1p + h2p < 360.0 {
225 (h1p + h2p + 360.0) / 2.0
226 } else {
227 (h1p + h2p - 360.0) / 2.0
228 };
229
230 let t = 1.0 - 0.17 * ((h_bar_p - 30.0).to_radians()).cos()
231 + 0.24 * ((2.0 * h_bar_p).to_radians()).cos()
232 + 0.32 * ((3.0 * h_bar_p + 6.0).to_radians()).cos()
233 - 0.20 * ((4.0 * h_bar_p - 63.0).to_radians()).cos();
234
235 let sl = 1.0 + 0.015 * (l_bar - 50.0).powi(2) / (20.0 + (l_bar - 50.0).powi(2)).sqrt();
236 let sc = 1.0 + 0.045 * c_bar_p;
237 let sh = 1.0 + 0.015 * c_bar_p * t;
238
239 let c_bar_p_7 = c_bar_p.powi(7);
240 let rt = -2.0
241 * (c_bar_p_7 / (c_bar_p_7 + 25.0_f64.powi(7))).sqrt()
242 * (60.0 * (-((h_bar_p - 275.0) / 25.0).powi(2)).exp())
243 .to_radians()
244 .sin();
245
246 ((dl / sl).powi(2) + (dc / sc).powi(2) + (dh / sh).powi(2) + rt * (dc / sc) * (dh / sh))
247 .sqrt()
248 }
249
250 fn srgb_delta_e(rgb1: [f32; 3], rgb2: [f32; 3]) -> f64 {
252 let lab1 = xyz_to_lab(srgb_to_xyz(rgb1[0], rgb1[1], rgb1[2]));
253 let lab2 = xyz_to_lab(srgb_to_xyz(rgb2[0], rgb2[1], rgb2[2]));
254 delta_e_2000(lab1, lab2)
255 }
256
257 #[test]
260 fn pipeline_basic() {
261 let transform = illuminant_d65();
262 let ks = rgb_to_ks(0.8, 0.2, 0.3).expect("pipeline should work");
263 let result = ks_to_srgb(&ks, transform);
264 for (c, &value) in result.iter().enumerate() {
266 assert!((0.0..=1.0).contains(&value), "ch {c}: {}", value);
267 }
268 }
269
270 fn representative_colors() -> Vec<(f32, f32, f32)> {
273 vec![
274 (1.0, 0.0, 0.0),
276 (0.0, 1.0, 0.0),
277 (0.0, 0.0, 1.0),
278 (1.0, 1.0, 0.0),
279 (1.0, 0.0, 1.0),
280 (0.0, 1.0, 1.0),
281 (1.0, 1.0, 1.0),
283 (0.0, 0.0, 0.0),
284 (0.25, 0.25, 0.25),
286 (0.5, 0.5, 0.5),
287 (0.75, 0.75, 0.75),
288 (1.0, 0.5, 0.0), (0.5, 1.0, 0.0), (0.0, 1.0, 0.5), (0.0, 0.5, 1.0), (0.5, 0.0, 1.0), (1.0, 0.0, 0.5), (0.8, 0.4, 0.2), (0.2, 0.6, 0.4), (0.6, 0.2, 0.8), (0.9, 0.9, 0.1), (0.1, 0.1, 0.9), (0.4, 0.7, 0.3), ]
302 }
303
304 fn round_trip_stats(colors: &[(f32, f32, f32)]) -> (f64, f64) {
305 let transform = illuminant_d65();
306 let mut sum_de = 0.0;
307 let mut max_de = 0.0f64;
308 for &(r, g, b) in colors {
309 let ks = rgb_to_ks(r, g, b).unwrap();
310 let out = ks_to_srgb(&ks, transform);
311 let de = srgb_delta_e([r, g, b], out);
312 sum_de += de;
313 max_de = max_de.max(de);
314 }
315 (sum_de / colors.len() as f64, max_de)
316 }
317
318 fn normalize_weighted_mix(colors: &[(&KsSpectrum, f32)]) -> KsSpectrum {
319 let mut mix = ks_mix_weighted(colors);
320 let sum_w: f32 = colors.iter().map(|(_, w)| *w).sum();
321 let inv = 1.0 / sum_w;
322 for value in mix.ks.iter_mut().take(N_SPECTRAL) {
323 *value *= inv;
324 }
325 mix
326 }
327
328 #[test]
329 fn round_trip_representative_colors() {
330 let colors = representative_colors();
331 assert!(colors.len() >= 22, "need at least 22 representative colors");
332
333 let (mean_de, max_de) = round_trip_stats(&colors);
334 eprintln!("Round-trip: mean ΔE₀₀ = {mean_de:.4}, max ΔE₀₀ = {max_de:.4}");
335
336 assert!(
337 mean_de < 0.01,
338 "mean ΔE₀₀ = {mean_de:.6} (required: < 0.01)"
339 );
340 assert!(max_de < 0.5, "max ΔE₀₀ = {max_de:.6} (required: < 0.5)");
341 }
342
343 #[test]
344 fn round_trip_stats_invariant_to_color_order() {
345 let mut colors = representative_colors();
346 let (mean_a, max_a) = round_trip_stats(&colors);
347 colors.reverse();
348 let (mean_b, max_b) = round_trip_stats(&colors);
349 assert!((mean_a - mean_b).abs() < 1e-12);
350 assert!((max_a - max_b).abs() < 1e-12);
351 }
352
353 #[test]
356 fn mixing_blue_yellow_gives_green_direction() {
357 let transform = illuminant_d65();
358 let blue_ks = rgb_to_ks(0.0, 0.0, 1.0).unwrap();
359 let yellow_ks = rgb_to_ks(1.0, 1.0, 0.0).unwrap();
360 let mixed = ks_mix(&blue_ks, &yellow_ks, 0.5);
361 let result = ks_to_srgb(&mixed, transform);
362 eprintln!(
363 "blue+yellow = [{:.3}, {:.3}, {:.3}]",
364 result[0], result[1], result[2]
365 );
366 assert!(
368 result[1] + result[2] > result[0] * 5.0,
369 "not green-ish (gray like RGB linear mixing): {:?}",
370 result
371 );
372 assert!(result[0] < 0.05, "R too high: {:?}", result);
373 }
374
375 #[test]
376 fn mixing_red_blue_gives_purple() {
377 let transform = illuminant_d65();
378 let red_ks = rgb_to_ks(1.0, 0.0, 0.0).unwrap();
379 let blue_ks = rgb_to_ks(0.0, 0.0, 1.0).unwrap();
380 let mixed = ks_mix(&red_ks, &blue_ks, 0.5);
381 let result = ks_to_srgb(&mixed, transform);
382 eprintln!(
383 "red+blue = [{:.3}, {:.3}, {:.3}]",
384 result[0], result[1], result[2]
385 );
386 assert!(
388 result[0] > result[1] && result[2] > result[1],
389 "not purple-ish: {:?}",
390 result
391 );
392 }
393
394 #[test]
395 fn mixing_red_white_gives_pink() {
396 let transform = illuminant_d65();
397 let red_ks = rgb_to_ks(1.0, 0.0, 0.0).unwrap();
398 let white_ks = rgb_to_ks(1.0, 1.0, 1.0).unwrap();
399 let mixed = ks_mix(&red_ks, &white_ks, 0.5);
400 let result = ks_to_srgb(&mixed, transform);
401 eprintln!(
402 "red+white = [{:.3}, {:.3}, {:.3}]",
403 result[0], result[1], result[2]
404 );
405 assert!(
407 result[0] > result[1] && result[0] > result[2],
408 "R not dominant: {:?}",
409 result
410 );
411 assert!(
412 result[1] > 0.0 && result[2] > 0.0,
413 "white mixing had no effect: {:?}",
414 result
415 );
416 }
417
418 #[test]
419 fn mixing_blue_white_gives_light_blue() {
420 let transform = illuminant_d65();
421 let blue_ks = rgb_to_ks(0.0, 0.0, 1.0).unwrap();
422 let white_ks = rgb_to_ks(1.0, 1.0, 1.0).unwrap();
423 let mixed = ks_mix(&blue_ks, &white_ks, 0.5);
424 let result = ks_to_srgb(&mixed, transform);
425 eprintln!(
426 "blue+white = [{:.3}, {:.3}, {:.3}]",
427 result[0], result[1], result[2]
428 );
429 assert!(
431 result[2] > result[0] && result[2] > result[1],
432 "B not dominant: {:?}",
433 result
434 );
435 assert!(result[1] > 0.0, "white mixing had no effect: {:?}", result);
436 }
437
438 #[test]
439 fn mixing_commutative_relation_in_srgb_space() {
440 let transform = illuminant_d65();
441 let red = rgb_to_ks(1.0, 0.0, 0.0).unwrap();
442 let blue = rgb_to_ks(0.0, 0.0, 1.0).unwrap();
443 let left = ks_to_srgb(&ks_mix(&red, &blue, 0.37), transform);
444 let right = ks_to_srgb(&ks_mix(&blue, &red, 0.63), transform);
445 let de = srgb_delta_e(left, right);
446 assert!(de < 1e-3, "ΔE₀₀={de}");
447 }
448
449 #[test]
450 fn weighted_mix_normalization_invariant_in_srgb_space() {
451 let transform = illuminant_d65();
452 let c1 = rgb_to_ks(1.0, 0.0, 0.0).unwrap();
453 let c2 = rgb_to_ks(0.0, 1.0, 0.0).unwrap();
454 let c3 = rgb_to_ks(0.0, 0.0, 1.0).unwrap();
455
456 let base = normalize_weighted_mix(&[(&c1, 0.2), (&c2, 0.3), (&c3, 0.5)]);
457 let scaled = normalize_weighted_mix(&[(&c1, 2.0), (&c2, 3.0), (&c3, 5.0)]);
458 let out_a = ks_to_srgb(&base, transform);
459 let out_b = ks_to_srgb(&scaled, transform);
460 let de = srgb_delta_e(out_a, out_b);
461 assert!(de < 1e-3, "ΔE₀₀={de}");
462 }
463
464 fn mixing_gt_401(coeffs_a: &SigmoidCoeffs, coeffs_b: &SigmoidCoeffs, t: f32) -> [f32; 3] {
468 use crate::illuminant::{CMF_X, CMF_Y, CMF_Z, ILLUMINANT_D65};
469
470 const N_GT: usize = 401;
471
472 let eval_401 = |coeffs: &SigmoidCoeffs| -> Vec<f64> {
474 (0..N_GT)
475 .map(|i| {
476 let lambda_nm = 380.0 + i as f64;
477 let t_norm = (lambda_nm - 380.0) / 400.0;
478 let x = coeffs.c0 as f64 * t_norm * t_norm
479 + coeffs.c1 as f64 * t_norm
480 + coeffs.c2 as f64;
481 0.5 + x / (2.0 * (1.0 + x * x).sqrt())
482 })
483 .collect()
484 };
485
486 let refl_a = eval_401(coeffs_a);
487 let refl_b = eval_401(coeffs_b);
488
489 let ks_max = 1000.0f64;
491 let refl_to_ks = |r: f64| -> f64 {
492 let r = r.clamp(0.0, 1.0);
493 if r < 1e-10 {
494 ks_max
495 } else {
496 ((1.0 - r) * (1.0 - r)) / (2.0 * r)
497 }
498 };
499 let ks_to_r = |ks: f64| -> f64 {
500 if ks >= ks_max {
501 0.0
502 } else if ks < 1e-6 {
503 (1.0 - (2.0 * ks).sqrt()).max(0.0)
504 } else {
505 (1.0 + ks - (ks * ks + 2.0 * ks).sqrt()).clamp(0.0, 1.0)
506 }
507 };
508
509 let t_f64 = t as f64;
510 let mixed_refl: Vec<f64> = refl_a
511 .iter()
512 .zip(refl_b.iter())
513 .map(|(&refl_a_i, &refl_b_i)| {
514 let ks_a = refl_to_ks(refl_a_i);
515 let ks_b = refl_to_ks(refl_b_i);
516 let ks_mix = (1.0 - t_f64) * ks_a + t_f64 * ks_b;
517 ks_to_r(ks_mix)
518 })
519 .collect();
520
521 let interp_cmf = |cmf: &[f32; 41], i_1nm: usize| -> f64 {
523 let idx_f = i_1nm as f64 / 10.0;
524 let idx = idx_f as usize;
525 if idx >= 40 {
526 return cmf[40] as f64;
527 }
528 let frac = idx_f - idx as f64;
529 cmf[idx] as f64 * (1.0 - frac) + cmf[idx + 1] as f64 * frac
530 };
531 let interp_illum = |i_1nm: usize| -> f64 {
532 let idx_f = i_1nm as f64 / 10.0;
533 let idx = idx_f as usize;
534 if idx >= 40 {
535 return ILLUMINANT_D65[40] as f64;
536 }
537 let frac = idx_f - idx as f64;
538 ILLUMINANT_D65[idx] as f64 * (1.0 - frac) + ILLUMINANT_D65[idx + 1] as f64 * frac
539 };
540
541 let mut sum_y_s = 0.0f64;
543 for i in 0..N_GT {
544 sum_y_s += interp_cmf(&CMF_Y, i) * interp_illum(i);
545 }
546 let k = 1.0 / sum_y_s;
547
548 let xyz_to_srgb: [[f64; 3]; 3] = [
550 [
551 3.2404541621141054,
552 -1.5371385940306089,
553 -0.49853140955601579,
554 ],
555 [
556 -0.969_266_030_505_183_1,
557 1.8760108454466942,
558 0.041556017530349834,
559 ],
560 [
561 0.055643430959114726,
562 -0.203_976_959_873_057_3,
563 1.0572251882231791,
564 ],
565 ];
566
567 let mut xyz = [0.0f64; 3];
568 for (i, &r) in mixed_refl.iter().enumerate().take(N_GT) {
569 let s = interp_illum(i);
570 xyz[0] += interp_cmf(&CMF_X, i) * s * r * k;
571 xyz[1] += interp_cmf(&CMF_Y, i) * s * r * k;
572 xyz[2] += interp_cmf(&CMF_Z, i) * s * r * k;
573 }
574
575 let linear_r =
576 xyz_to_srgb[0][0] * xyz[0] + xyz_to_srgb[0][1] * xyz[1] + xyz_to_srgb[0][2] * xyz[2];
577 let linear_g =
578 xyz_to_srgb[1][0] * xyz[0] + xyz_to_srgb[1][1] * xyz[1] + xyz_to_srgb[1][2] * xyz[2];
579 let linear_b =
580 xyz_to_srgb[2][0] * xyz[0] + xyz_to_srgb[2][1] * xyz[1] + xyz_to_srgb[2][2] * xyz[2];
581
582 [
583 neco_color::linear_to_srgb(linear_r.max(0.0) as f32).min(1.0),
584 neco_color::linear_to_srgb(linear_g.max(0.0) as f32).min(1.0),
585 neco_color::linear_to_srgb(linear_b.max(0.0) as f32).min(1.0),
586 ]
587 }
588
589 #[test]
590 fn mixing_quantitative_gt_comparison() {
591 let transform = illuminant_d65();
592 type ColorPairCase = ((f32, f32, f32), (f32, f32, f32), &'static str);
593 let pairs: &[ColorPairCase] = &[
594 ((0.0, 0.0, 1.0), (1.0, 1.0, 0.0), "blue+yellow"),
595 ((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), "red+blue"),
596 ((1.0, 0.0, 0.0), (1.0, 1.0, 1.0), "red+white"),
597 ((0.0, 0.0, 1.0), (1.0, 1.0, 1.0), "blue+white"),
598 ];
599
600 for &((r1, g1, b1), (r2, g2, b2), name) in pairs {
601 let coeffs_a = rgb_to_sigmoid(r1, g1, b1).unwrap();
602 let coeffs_b = rgb_to_sigmoid(r2, g2, b2).unwrap();
603
604 let ks_a = rgb_to_ks(r1, g1, b1).unwrap();
606 let ks_b = rgb_to_ks(r2, g2, b2).unwrap();
607 let mixed_41 = ks_mix(&ks_a, &ks_b, 0.5);
608 let result_41 = ks_to_srgb(&mixed_41, transform);
609
610 let result_401 = mixing_gt_401(&coeffs_a, &coeffs_b, 0.5);
612
613 let de = srgb_delta_e(result_41, result_401);
614 eprintln!(
615 "{name}: N=41 [{:.3},{:.3},{:.3}] vs GT [{:.3},{:.3},{:.3}], ΔE₀₀ = {de:.4}",
616 result_41[0],
617 result_41[1],
618 result_41[2],
619 result_401[0],
620 result_401[1],
621 result_401[2]
622 );
623
624 assert!(de < 1.0, "{name}: ΔE₀₀ = {de:.4} (required: < 1.0)");
625 }
626 }
627
628 #[test]
631 fn stability_black() {
632 let transform = illuminant_d65();
633 let ks = rgb_to_ks(0.0, 0.0, 0.0).unwrap();
634 let _result = ks_to_srgb(&ks, transform);
635 }
637
638 #[test]
639 fn stability_white() {
640 let transform = illuminant_d65();
641 let ks = rgb_to_ks(1.0, 1.0, 1.0).unwrap();
642 let _result = ks_to_srgb(&ks, transform);
643 }
644
645 #[test]
646 fn stability_high_saturation() {
647 let transform = illuminant_d65();
648 let saturated = [(1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)];
649 for (r, g, b) in saturated {
650 let ks = rgb_to_ks(r, g, b).unwrap();
651 let result = ks_to_srgb(&ks, transform);
652 for (c, &value) in result.iter().enumerate() {
653 assert!(
654 value.is_finite(),
655 "({r},{g},{b}) ch {c} is not finite: {}",
656 value
657 );
658 }
659 }
660 }
661
662 #[test]
665 fn gn_convergence_all_representative_colors() {
666 let colors = representative_colors();
667 for &(r, g, b) in &colors {
668 rgb_to_sigmoid(r, g, b).unwrap_or_else(|e| panic!("GN failed for ({r},{g},{b}): {e}"));
669 }
670 }
671
672 #[test]
675 fn pigment_from_srgb_basic() {
676 let colors = representative_colors();
677 for &(r, g, b) in &colors {
678 let p = Pigment::from_srgb(r, g, b)
679 .unwrap_or_else(|e| panic!("Pigment::from_srgb({r},{g},{b}) failed: {e}"));
680 assert_eq!(p.srgb, [r, g, b]);
681 }
682 }
683
684 #[test]
685 fn pigment_black_sentinel() {
686 let p = Pigment::from_srgb(0.0, 0.0, 0.0).unwrap();
687 assert_eq!(p.srgb, [0.0, 0.0, 0.0]);
688 assert_eq!(p.coeffs.c0, 0.0);
689 assert_eq!(p.coeffs.c1, 0.0);
690 assert_eq!(p.coeffs.c2, -1e6);
691 assert_eq!(p.ks.ks, [1000.0; N_SPECTRAL]);
692 }
693
694 #[test]
695 fn pigment_white_sentinel() {
696 let p = Pigment::from_srgb(1.0, 1.0, 1.0).unwrap();
697 assert_eq!(p.srgb, [1.0, 1.0, 1.0]);
698 assert_eq!(p.coeffs.c0, 0.0);
699 assert_eq!(p.coeffs.c1, 0.0);
700 assert_eq!(p.coeffs.c2, 1e6);
701 assert_eq!(p.ks.ks, [0.0; N_SPECTRAL]);
702 }
703
704 #[test]
705 fn pigment_spectrum_matches_sigmoid_to_spectrum() {
706 let colors = representative_colors();
707 for &(r, g, b) in &colors {
708 let p = Pigment::from_srgb(r, g, b).unwrap();
709 let direct = sigmoid_to_spectrum(&p.coeffs);
710 assert_eq!(p.spectrum(), direct, "mismatch for ({r},{g},{b})");
711 }
712 }
713
714 #[test]
715 fn pigment_ks_matches_rgb_to_ks() {
716 let colors = representative_colors();
717 for &(r, g, b) in &colors {
718 let p = Pigment::from_srgb(r, g, b).unwrap();
719 let ks = rgb_to_ks(r, g, b).unwrap();
720 assert_eq!(p.ks.ks, ks.ks, "K/S mismatch for ({r},{g},{b})");
721 }
722 }
723
724 #[test]
725 fn pigment_clone_and_debug() {
726 let p = Pigment::from_srgb(0.5, 0.3, 0.7).unwrap();
727 let p2 = p.clone();
728 assert_eq!(p.srgb, p2.srgb);
729 assert_eq!(p.ks.ks, p2.ks.ks);
730 let dbg = format!("{:?}", p);
732 assert!(dbg.contains("Pigment"));
733 }
734}