Skip to main content

neco_pigment/
lib.rs

1//! Kubelka-Munk spectral pigment mixing crate.
2
3mod 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
16// Re-export neco-color gamma functions.
17pub use neco_color::{
18    linear_to_srgb, linear_to_srgb_lut, srgb_to_linear, srgb_to_linear_lut, to_u8,
19};
20
21/// Error type for neco-pigment.
22#[derive(Debug, Clone)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub enum PigmentError {
25    /// Gauss-Newton optimization did not converge.
26    ConvergenceFailure {
27        /// Final residual L2 norm.
28        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/// Bundled pigment holding the original sRGB, sigmoid coefficients, and K/S spectrum.
51#[derive(Clone, Debug)]
52pub struct Pigment {
53    pub srgb: [f32; 3],
54    pub coeffs: SigmoidCoeffs,
55    pub ks: KsSpectrum,
56}
57
58impl Pigment {
59    /// Create a pigment from sRGB components.
60    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    /// Reconstruct the reflectance spectrum from sigmoid coefficients.
102    pub fn spectrum(&self) -> [f32; N_SPECTRAL] {
103        sigmoid_to_spectrum(&self.coeffs)
104    }
105}
106
107/// Convert sRGB to K/S spectrum (handles black/white analytically).
108pub 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
128/// Convert K/S spectrum to sRGB (using LUT gamma).
129pub 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    // CIEDE2000 test utilities
144
145    /// sRGB → XYZ (D65)
146    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        // sRGB to XYZ (D65)
151        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    /// XYZ → Lab (D65)
158    fn xyz_to_lab(xyz: [f64; 3]) -> [f64; 3] {
159        // D65 white point
160        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    /// CIEDE2000 color difference.
183    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    /// Compute CIEDE2000 between two sRGB colors.
251    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    // Pipeline smoke test
258
259    #[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        // Output should be in valid range
265        for (c, &value) in result.iter().enumerate() {
266            assert!((0.0..=1.0).contains(&value), "ch {c}: {}", value);
267        }
268    }
269
270    // Round-trip test (22+ colors)
271
272    fn representative_colors() -> Vec<(f32, f32, f32)> {
273        vec![
274            // 6 primaries
275            (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            // Black & white
282            (1.0, 1.0, 1.0),
283            (0.0, 0.0, 0.0),
284            // Grays
285            (0.25, 0.25, 0.25),
286            (0.5, 0.5, 0.5),
287            (0.75, 0.75, 0.75),
288            // 12 intermediate colors
289            (1.0, 0.5, 0.0), // Orange
290            (0.5, 1.0, 0.0), // Lime
291            (0.0, 1.0, 0.5), // Spring green
292            (0.0, 0.5, 1.0), // Azure
293            (0.5, 0.0, 1.0), // Violet
294            (1.0, 0.0, 0.5), // Rose
295            (0.8, 0.4, 0.2), // Brown
296            (0.2, 0.6, 0.4), // Teal
297            (0.6, 0.2, 0.8), // Purple
298            (0.9, 0.9, 0.1), // Bright yellow
299            (0.1, 0.1, 0.9), // Dark blue
300            (0.4, 0.7, 0.3), // Grass green
301        ]
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    // Mixing tests
354
355    #[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        // KM mixing: blue+yellow should produce green/teal, not gray
367        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        // Purple: R and B should exceed G
387        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        // Pink: R should be dominant
406        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        // Light blue: B should be dominant
430        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    // Quantitative mixing test: N=41 vs N=401 ground truth
465
466    /// Ground-truth mixing at 1nm resolution (N=401).
467    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        // Evaluate sigmoid spectrum at 1nm resolution
473        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        // KM mixing in K/S space
490        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        // XYZ integration at 401 points (linearly interpolated 10nm CMF to 1nm)
522        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        // Y normalization
542        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        // XYZ to linear sRGB (D65)
549        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            // N=41 result
605            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            // N=401 ground truth
611            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    // Numerical stability tests
629
630    #[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        // Should not panic
636    }
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    // GN convergence test for all representative colors
663
664    #[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    // Pigment tests
673
674    #[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        // Debug trait produces non-empty output
731        let dbg = format!("{:?}", p);
732        assert!(dbg.contains("Pigment"));
733    }
734}