spectro_rs/
colorimetry.rs

1use crate::{Illuminant, Observer};
2
3/// CIE 1931 2-degree Standard Observer CMFs (380-780nm, 10nm steps)
4pub const X_BAR_2: [f32; 41] = [
5    0.0014, 0.0042, 0.0143, 0.0435, 0.1344, 0.2839, 0.3483, 0.3362, 0.2908, 0.1954, 0.0956, 0.0320,
6    0.0049, 0.0093, 0.0633, 0.1655, 0.2904, 0.4334, 0.5945, 0.7621, 0.9163, 1.0263, 1.0622, 1.0026,
7    0.8524, 0.6424, 0.4479, 0.2835, 0.1649, 0.0874, 0.0468, 0.0227, 0.0114, 0.0058, 0.0029, 0.0014,
8    0.00069, 0.00033, 0.00017, 0.00008, 0.00004,
9];
10
11pub const Y_BAR_2: [f32; 41] = [
12    0.0000, 0.0001, 0.0004, 0.0012, 0.0040, 0.0116, 0.0230, 0.0380, 0.0600, 0.0910, 0.1390, 0.2080,
13    0.3230, 0.5030, 0.7100, 0.8620, 0.9540, 0.9950, 0.9950, 0.9520, 0.8700, 0.7570, 0.6310, 0.5030,
14    0.3810, 0.2650, 0.1750, 0.1070, 0.0610, 0.0320, 0.0170, 0.0082, 0.0041, 0.0021, 0.0010,
15    0.00052, 0.00025, 0.00012, 0.00006, 0.00003, 0.00001,
16];
17
18pub const Z_BAR_2: [f32; 41] = [
19    0.0065, 0.0201, 0.0679, 0.2074, 0.6456, 1.3856, 1.7471, 1.7721, 1.5794, 1.1143, 0.5701, 0.1970,
20    0.0415, 0.0052, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
21    0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
22    0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
23];
24
25/// CIE 1964 10-degree Standard Observer CMFs (380-780nm, 10nm steps)
26#[expect(
27    clippy::approx_constant,
28    reason = "Standard CIE constant with prescribed precision"
29)]
30pub const X_BAR_10: [f32; 41] = [
31    0.0002, 0.0011, 0.0061, 0.0315, 0.1241, 0.3023, 0.5045, 0.6931, 0.8177, 0.7530, 0.5314, 0.3345,
32    0.1570, 0.0538, 0.0331, 0.1117, 0.2230, 0.4243, 0.6627, 0.8690, 1.0107, 1.0743, 1.0257, 0.8724,
33    0.6553, 0.4456, 0.2800, 0.1622, 0.0869, 0.0434, 0.0218, 0.0107, 0.0053, 0.0026, 0.0013, 0.0006,
34    0.0003, 0.0001, 0.0000, 0.0000, 0.0000,
35];
36pub const Y_BAR_10: [f32; 41] = [
37    0.0000, 0.0000, 0.0002, 0.0010, 0.0041, 0.0105, 0.0207, 0.0407, 0.0702, 0.1120, 0.1852, 0.2904,
38    0.4190, 0.5764, 0.7435, 0.8872, 0.9666, 0.9983, 0.9873, 0.9331, 0.8420, 0.7163, 0.5596, 0.4203,
39    0.3021, 0.2003, 0.1245, 0.0713, 0.0380, 0.0189, 0.0094, 0.0046, 0.0023, 0.0111, 0.0006, 0.0003,
40    0.0001, 0.0000, 0.0000, 0.0000, 0.0000,
41];
42pub const Z_BAR_10: [f32; 41] = [
43    0.0007, 0.0045, 0.0259, 0.1343, 0.5285, 1.3003, 2.1932, 3.0334, 3.5534, 3.2392, 2.2235, 1.3400,
44    0.5752, 0.1866, 0.0427, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
45    0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
46    0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
47];
48
49// X_BAR_10, Y_BAR_10, Z_BAR_10 are already pub const.
50// No changes needed to visibility.
51
52/// CIE 2015 Physiologically-based LMS Color Matching Functions (2-degree observer, 10nm)
53/// These represent the real cone response of the human eye.
54pub const L_BAR_2015: [f32; 41] = [
55    0.0001, 0.0004, 0.0019, 0.0084, 0.0292, 0.0544, 0.0652, 0.0660, 0.0536, 0.0336, 0.0253, 0.0435,
56    0.0906, 0.1834, 0.3541, 0.5363, 0.7024, 0.8358, 0.9328, 0.9859, 1.0000, 0.9575, 0.8524, 0.7081,
57    0.5480, 0.3952, 0.2644, 0.1651, 0.0967, 0.0538, 0.0284, 0.0143, 0.0068, 0.0031, 0.0014, 0.0006,
58    0.0003, 0.0001, 0.0001, 0.0000, 0.0000,
59];
60pub const M_BAR_2015: [f32; 41] = [
61    0.0000, 0.0001, 0.0006, 0.0028, 0.0121, 0.0298, 0.0450, 0.0526, 0.0519, 0.0440, 0.0494, 0.0772,
62    0.1345, 0.2319, 0.3802, 0.5312, 0.6724, 0.7974, 0.8926, 0.9515, 0.9757, 0.9592, 0.8995, 0.7963,
63    0.6621, 0.5134, 0.3698, 0.2486, 0.1557, 0.0917, 0.0511, 0.0270, 0.0135, 0.0064, 0.0030, 0.0013,
64    0.0006, 0.0003, 0.0001, 0.0001, 0.0000,
65];
66pub const S_BAR_2015: [f32; 41] = [
67    0.0019, 0.0101, 0.0469, 0.1648, 0.4449, 0.8443, 0.9930, 0.8970, 0.6171, 0.3392, 0.1505, 0.0532,
68    0.1042, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
69    0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
70    0.0000, 0.0000, 0.0000, 0.0000, 0.0000,
71];
72
73/// CIE Standard Illuminants.
74/// IMPORTANT: Use the correct observer angle (2° or 10°) matching your CMFs.
75pub mod illuminant {
76    use super::XYZ;
77
78    // ==================== 2-DEGREE OBSERVER ====================
79    /// D50 (Horizon Light, Print Industry - 2°)
80    pub const D50: XYZ = XYZ {
81        x: 0.96422,
82        y: 1.0,
83        z: 0.82521,
84    };
85    /// D55 (Mid-Morning Daylight - 2°)
86    pub const D55: XYZ = XYZ {
87        x: 0.95682,
88        y: 1.0,
89        z: 0.92149,
90    };
91    /// D65 (Noon Daylight, sRGB Standard - 2°)
92    pub const D65: XYZ = XYZ {
93        x: 0.95047,
94        y: 1.0,
95        z: 1.08883,
96    };
97    /// D75 (North Sky Daylight - 2°)
98    pub const D75: XYZ = XYZ {
99        x: 0.94972,
100        y: 1.0,
101        z: 1.22638,
102    };
103    /// Illuminant A (Tungsten 2856K - 2°)
104    pub const A: XYZ = XYZ {
105        x: 1.09850,
106        y: 1.0,
107        z: 0.35585,
108    };
109    /// F2 (Cool White Fluorescent - 2°)
110    pub const F2: XYZ = XYZ {
111        x: 0.99186,
112        y: 1.0,
113        z: 0.67393,
114    };
115    /// F7 (Daylight Fluorescent - 2°)
116    pub const F7: XYZ = XYZ {
117        x: 0.95041,
118        y: 1.0,
119        z: 1.08747,
120    };
121    /// F11 (TL84 Narrow Band - 2°)
122    pub const F11: XYZ = XYZ {
123        x: 1.00962,
124        y: 1.0,
125        z: 0.64350,
126    };
127
128    // ==================== 10-DEGREE OBSERVER ====================
129    /// D50 (10-degree observer)
130    pub const D50_10: XYZ = XYZ {
131        x: 0.96720,
132        y: 1.0,
133        z: 0.81427,
134    };
135    /// D55 (10-degree observer)
136    pub const D55_10: XYZ = XYZ {
137        x: 0.95799,
138        y: 1.0,
139        z: 0.90926,
140    };
141    /// D65 (10-degree observer)
142    pub const D65_10: XYZ = XYZ {
143        x: 0.94811,
144        y: 1.0,
145        z: 1.07304,
146    };
147    /// D75 (10-degree observer)
148    pub const D75_10: XYZ = XYZ {
149        x: 0.94416,
150        y: 1.0,
151        z: 1.20641,
152    };
153    /// Illuminant A (10-degree observer)
154    pub const A_10: XYZ = XYZ {
155        x: 1.11144,
156        y: 1.0,
157        z: 0.35200,
158    };
159
160    /// CIE 2018 LED Series Illuminants (2-degree).
161    /// These replace old F-series for modern lighting analysis.
162    pub mod led {
163        use super::super::XYZ;
164        /// LED-B1: Typical Blue-pumped white LED (2733K)
165        pub const B1: XYZ = XYZ {
166            x: 1.0967,
167            y: 1.0,
168            z: 0.3533,
169        };
170        /// LED-B3: Standard white LED (4103K)
171        pub const B3: XYZ = XYZ {
172            x: 1.0031,
173            y: 1.0,
174            z: 0.5361,
175        };
176        /// LED-B5: High CRI white LED (6598K)
177        pub const B5: XYZ = XYZ {
178            x: 0.9482,
179            y: 1.0,
180            z: 1.0642,
181        };
182        /// LED-BH1: Hybrid warm LED (2851K)
183        pub const BH1: XYZ = XYZ {
184            x: 1.0824,
185            y: 1.0,
186            z: 0.3592,
187        };
188    }
189
190    // Legacy aliases for backward compatibility
191    pub const D55_2: XYZ = D55;
192    pub const D65_2: XYZ = D65;
193
194    /// Relative Spectral Power Distributions (380-780nm, 10nm steps)
195    pub mod spd {
196        /// CIE Standard Illuminant A (Tungsten)
197        pub const A: [f32; 41] = [
198            9.80, 12.09, 14.71, 17.68, 21.00, 24.67, 28.70, 33.09, 37.81, 42.87, 48.24, 53.91,
199            59.86, 66.06, 72.50, 79.13, 85.95, 92.91, 100.00, 107.18, 114.44, 121.73, 129.04,
200            136.35, 143.62, 150.84, 157.98, 165.03, 171.96, 178.77, 185.43, 191.93, 198.26, 204.41,
201            210.37, 216.12, 221.67, 227.00, 232.12, 237.01, 241.68,
202        ];
203
204        /// CIE Standard Illuminant D50 (Horizon Daylight)
205        pub const D50: [f32; 41] = [
206            24.49, 29.87, 49.31, 56.51, 60.03, 57.82, 74.82, 87.25, 90.61, 91.37, 95.11, 91.96,
207            95.72, 96.61, 97.13, 102.10, 100.75, 102.32, 100.00, 97.74, 98.92, 93.50, 97.69, 99.27,
208            99.04, 95.72, 98.86, 95.67, 98.19, 103.00, 99.13, 87.38, 91.60, 92.89, 76.85, 86.51,
209            92.58, 78.23, 57.69, 82.92, 78.27,
210        ];
211
212        /// CIE Standard Illuminant D65 (Noon Daylight)
213        pub const D65: [f32; 41] = [
214            49.98, 54.65, 82.75, 91.49, 93.43, 86.68, 104.87, 117.01, 117.81, 114.86, 115.92,
215            108.81, 109.35, 107.80, 104.79, 107.69, 104.41, 104.05, 100.00, 96.33, 95.79, 88.69,
216            90.01, 89.60, 87.70, 83.29, 83.70, 80.03, 80.21, 82.28, 78.28, 69.72, 71.61, 74.35,
217            61.60, 69.89, 75.09, 63.59, 46.42, 66.81, 63.38,
218        ];
219
220        /// CIE Standard Illuminant F2 (Cool White Fluorescent)
221        pub const F2: [f32; 41] = [
222            1.87, 2.94, 5.17, 6.13, 7.01, 8.56, 43.67, 16.94, 11.35, 12.37, 13.00, 13.23, 13.13,
223            12.52, 11.83, 11.22, 11.03, 11.53, 27.74, 17.05, 14.33, 15.52, 19.55, 14.91, 13.22,
224            11.12, 8.95, 7.02, 5.42, 4.15, 3.20, 2.47, 1.93, 1.67, 1.29, 1.08, 0.88, 0.77, 0.73,
225            0.69, 0.68,
226        ];
227
228        /// CIE Standard Illuminant F7 (Broadband Fluorescent)
229        pub const F7: [f32; 41] = [
230            1.87, 2.92, 5.10, 6.00, 6.85, 8.31, 40.76, 16.06, 10.91, 11.83, 12.40, 12.58, 12.47,
231            11.89, 11.33, 10.96, 11.16, 12.12, 27.78, 17.73, 15.20, 16.10, 19.50, 14.64, 12.69,
232            10.45, 8.29, 6.41, 4.90, 3.72, 2.83, 2.19, 1.71, 1.43, 1.13, 0.96, 0.78, 0.68, 0.65,
233            0.62, 0.62,
234        ];
235
236        /// CIE Standard Illuminant F11 (Narrowband Fluorescent)
237        pub const F11: [f32; 41] = [
238            1.87, 2.35, 2.92, 3.45, 5.10, 18.91, 6.00, 6.11, 6.85, 7.58, 8.31, 40.76, 16.06, 10.32,
239            10.91, 11.40, 11.83, 12.17, 12.40, 12.54, 12.58, 12.52, 12.47, 12.20, 11.89, 11.61,
240            11.33, 11.10, 10.96, 10.97, 11.16, 11.54, 12.12, 27.78, 17.73, 14.47, 15.20, 15.77,
241            16.10, 18.54, 19.50,
242        ];
243    }
244}
245
246impl Illuminant {
247    /// Get the relative spectral power distribution for this illuminant.
248    pub fn get_spd(&self) -> &'static [f32; 41] {
249        match self {
250            Illuminant::A => &illuminant::spd::A,
251            Illuminant::D50 => &illuminant::spd::D50,
252            Illuminant::D55 => &illuminant::spd::D65, // Fallback to D65 if D55 SPD missing
253            Illuminant::D65 => &illuminant::spd::D65,
254            Illuminant::D75 => &illuminant::spd::D65, // Fallback
255            Illuminant::F2 => &illuminant::spd::F2,
256            Illuminant::F7 => &illuminant::spd::F7,
257            Illuminant::F11 => &illuminant::spd::F11,
258        }
259    }
260
261    pub fn get_white_point(&self, observer: Observer) -> XYZ {
262        match observer {
263            Observer::CIE1931_2 => match self {
264                Illuminant::D50 => illuminant::D50,
265                Illuminant::D55 => illuminant::D55,
266                Illuminant::D65 => illuminant::D65,
267                Illuminant::D75 => illuminant::D75,
268                Illuminant::A => illuminant::A,
269                Illuminant::F2 => illuminant::F2,
270                Illuminant::F7 => illuminant::F7,
271                Illuminant::F11 => illuminant::F11,
272            },
273            Observer::CIE1964_10 => match self {
274                Illuminant::D50 => illuminant::D50_10,
275                Illuminant::D55 => illuminant::D55_10,
276                Illuminant::D65 => illuminant::D65_10,
277                Illuminant::D75 => illuminant::D75_10,
278                Illuminant::A => illuminant::A_10,
279                // Fallback to 2-degree if 10-degree constants are missing
280                Illuminant::F2 => illuminant::F2,
281                Illuminant::F7 => illuminant::F7,
282                Illuminant::F11 => illuminant::F11,
283            },
284        }
285    }
286}
287
288impl Observer {
289    pub fn get_cmfs(&self) -> (&'static [f32; 41], &'static [f32; 41], &'static [f32; 41]) {
290        match self {
291            Observer::CIE1931_2 => (&X_BAR_2, &Y_BAR_2, &Z_BAR_2),
292            Observer::CIE1964_10 => (&X_BAR_10, &Y_BAR_10, &Z_BAR_10),
293        }
294    }
295}
296
297/// ASTM E308 Weighting Factors for D65/2° at 10nm.
298/// These factors include spectral bandwidth compensation and are the 
299/// industry standard for computing tristimulus values from reflectance.
300#[rustfmt::skip]
301pub mod weighting {
302    /// Tristimulus weighting factors for X (D65, 2-degree, 10nm, 380-780nm)
303    pub const WX_D65_2_10: [f32; 41] = [
304        0.007, 0.022, 0.112, 0.377, 1.188, 2.330, 3.459, 3.724, 3.243, 2.126, 
305        1.049, 0.330, 0.051, 0.095, 0.628, 1.687, 2.870, 4.267, 5.628, 6.948, 
306        8.310, 8.618, 9.050, 8.505, 7.077, 5.066, 3.549, 2.147, 1.252, 0.681, 
307        0.347, 0.150, 0.077, 0.041, 0.017, 0.009, 0.005, 0.002, 0.001, 0.001, 
308        0.000
309    ];
310
311    /// Tristimulus weighting factors for Y (D65, 2-degree, 10nm, 380-780nm)
312    pub const WY_D65_2_10: [f32; 41] = [
313        0.000, 0.001, 0.003, 0.010, 0.035, 0.095, 0.228, 0.421, 0.669, 0.989, 
314        1.524, 2.141, 3.344, 5.131, 7.041, 8.785, 9.425, 9.792, 9.416, 8.675, 
315        7.887, 6.354, 5.374, 4.265, 3.162, 2.089, 1.386, 0.810, 0.463, 0.249, 
316        0.126, 0.054, 0.028, 0.015, 0.006, 0.003, 0.002, 0.001, 0.000, 0.000, 
317        0.000
318    ];
319
320    /// Tristimulus weighting factors for Z (D65, 2-degree, 10nm, 380-780nm)
321    pub const WZ_D65_2_10: [f32; 41] = [
322        0.035, 0.119, 0.610, 2.059, 6.541, 13.031, 19.880, 22.491, 20.182, 13.888, 
323        7.167, 2.325, 0.492, 0.061, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 
324        0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 
325        0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 
326        0.000
327    ];
328
329    /// Sum of WY_D65_2_10 weights. 
330    /// Corrected to 100.0 to simplify normalization logic.
331    pub const SUM_WY_D65_2_10: f32 = 100.000;
332
333    // ==================== D50 (Print Industry Standard) ====================
334    
335    /// Tristimulus weighting factors for X (D50, 2-degree, 10nm, 380-780nm)
336    /// Target White Point: X=96.42, Y=100.00, Z=82.52 (CIE 15:2004)
337    pub const WX_D50_2_10: [f32; 41] = [
338        0.003, 0.012, 0.067, 0.235, 0.770, 1.566, 2.483, 2.794, 2.510, 1.700, 
339        0.866, 0.280, 0.045, 0.086, 0.585, 1.608, 2.785, 4.221, 5.659, 7.090, 
340        8.627, 9.137, 9.882, 9.480, 8.042, 5.858, 4.219, 2.585, 1.543, 0.858, 
341        0.442, 0.189, 0.100, 0.051, 0.021, 0.012, 0.006, 0.002, 0.001, 0.001, 
342        0.000
343    ];
344
345    /// Tristimulus weighting factors for Y (D50, 2-degree, 10nm, 380-780nm)
346    pub const WY_D50_2_10: [f32; 41] = [
347        0.000, 0.000, 0.002, 0.006, 0.023, 0.064, 0.164, 0.316, 0.518, 0.792, 
348        1.259, 1.821, 2.943, 4.626, 6.563, 8.376, 9.148, 9.688, 9.469, 8.854, 
349        8.190, 6.738, 5.869, 4.755, 3.594, 2.416, 1.648, 0.975, 0.571, 0.314, 
350        0.161, 0.068, 0.036, 0.019, 0.007, 0.004, 0.002, 0.001, 0.001, 0.000, 
351        0.000
352    ];
353
354    /// Tristimulus weighting factors for Z (D50, 2-degree, 10nm, 380-780nm)
355    pub const WZ_D50_2_10: [f32; 41] = [
356        0.018, 0.067, 0.373, 1.306, 4.318, 8.921, 14.544, 17.196, 15.915, 11.320, 
357        6.028, 2.014, 0.442, 0.056, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 
358        0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 
359        0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 
360        0.000
361    ];
362
363    /// Sum of WY_D50_2_10 weights.
364    pub const SUM_WY_D50_2_10: f32 = 100.000;
365}
366
367/// Bradford chromatic adaptation transform.
368/// Converts XYZ from one illuminant to another using the Bradford cone response model.
369///
370/// Reference: Lindbloom (http://www.brucelindbloom.com/index.html?Eqn_ChromAdapt.html)
371/// Note: The Bradford matrix used here is the "Sharp" variant commonly used in ICC profiles.
372pub mod chromatic_adaptation {
373    use super::XYZ;
374
375    /// Apply Bradford transform to adapt XYZ from source to destination white point.
376    ///
377    /// # Example
378    /// ```
379    /// use spectro_rs::colorimetry::{XYZ, illuminant, chromatic_adaptation};
380    /// let xyz_d50 = XYZ { x: 0.5, y: 0.5, z: 0.4 };
381    /// let xyz_d65 = chromatic_adaptation::bradford_adapt(xyz_d50, illuminant::D50, illuminant::D65);
382    /// ```
383    #[expect(
384        clippy::excessive_precision,
385        reason = "The Bradford transform matrix requires high precision as defined in the ICC/Lindbloom standard"
386    )]
387    pub fn bradford_adapt(xyz: XYZ, src_wp: XYZ, dst_wp: XYZ) -> XYZ {
388        // Bradford M matrix (XYZ to LMS cone response)
389        // Source: Bruce Lindbloom, ICC Profile specification
390        #[rustfmt::skip]
391        let m = [
392            [ 0.8951000,  0.2664000, -0.1614000],
393            [-0.7502000,  1.7135000,  0.0367000],
394            [ 0.0389000, -0.0685000,  1.0296000],
395        ];
396        // Inverse Bradford M matrix (computed to match M exactly)
397        #[rustfmt::skip]
398        let m_inv = [
399            [ 0.9869929, -0.1470543,  0.1599627],
400            [ 0.4323053,  0.5183603,  0.0492912],
401            [-0.0085287,  0.0400428,  0.9684867],
402        ];
403
404        // Convert to LMS
405        let src_lms = [
406            m[0][0] * src_wp.x + m[0][1] * src_wp.y + m[0][2] * src_wp.z,
407            m[1][0] * src_wp.x + m[1][1] * src_wp.y + m[1][2] * src_wp.z,
408            m[2][0] * src_wp.x + m[2][1] * src_wp.y + m[2][2] * src_wp.z,
409        ];
410        let dst_lms = [
411            m[0][0] * dst_wp.x + m[0][1] * dst_wp.y + m[0][2] * dst_wp.z,
412            m[1][0] * dst_wp.x + m[1][1] * dst_wp.y + m[1][2] * dst_wp.z,
413            m[2][0] * dst_wp.x + m[2][1] * dst_wp.y + m[2][2] * dst_wp.z,
414        ];
415
416        // Scaling factors
417        let scale = [
418            dst_lms[0] / src_lms[0],
419            dst_lms[1] / src_lms[1],
420            dst_lms[2] / src_lms[2],
421        ];
422
423        // Convert input XYZ to LMS
424        let lms = [
425            m[0][0] * xyz.x + m[0][1] * xyz.y + m[0][2] * xyz.z,
426            m[1][0] * xyz.x + m[1][1] * xyz.y + m[1][2] * xyz.z,
427            m[2][0] * xyz.x + m[2][1] * xyz.y + m[2][2] * xyz.z,
428        ];
429
430        // Scale LMS
431        let lms_adapted = [lms[0] * scale[0], lms[1] * scale[1], lms[2] * scale[2]];
432
433        // Convert back to XYZ
434        XYZ {
435            x: m_inv[0][0] * lms_adapted[0]
436                + m_inv[0][1] * lms_adapted[1]
437                + m_inv[0][2] * lms_adapted[2],
438            y: m_inv[1][0] * lms_adapted[0]
439                + m_inv[1][1] * lms_adapted[1]
440                + m_inv[1][2] * lms_adapted[2],
441            z: m_inv[2][0] * lms_adapted[0]
442                + m_inv[2][1] * lms_adapted[1]
443                + m_inv[2][2] * lms_adapted[2],
444        }
445    }
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
449pub struct XYZ {
450    pub x: f32,
451    pub y: f32,
452    pub z: f32,
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
456pub struct Lab {
457    pub l: f32,
458    pub a: f32,
459    pub b: f32,
460}
461
462/// LMS color space representing cone responses (Long, Medium, Short wavelengths).
463/// Based on CIE 2015 physiologically-based sensitivity data.
464#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
465pub struct LMS {
466    pub l: f32,
467    pub m: f32,
468    pub s: f32,
469}
470
471/// Jzazbz: A modern perceptually uniform color space (Safdar et al., 2017).
472/// Designed for HDR content with excellent uniformity across the entire
473/// luminance range (0-10,000 nits). Euclidean distance in this space
474/// directly represents perceptual difference.
475#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
476pub struct Jzazbz {
477    /// Lightness (0.0 = black, higher = brighter, no upper limit for HDR)
478    pub jz: f32,
479    /// Red-green opponent (negative = green, positive = red)
480    pub az: f32,
481    /// Blue-yellow opponent (negative = blue, positive = yellow)
482    pub bz: f32,
483}
484
485pub fn calculate_cct(spd: &crate::spectrum::SpectralData) -> (f32, f32) {
486    let xyz = spd.to_xyz();
487    let sum = xyz.x + xyz.y + xyz.z;
488    if sum == 0.0 {
489        return (0.0, 0.0);
490    }
491    let x = xyz.x / sum;
492    let y = xyz.y / sum;
493
494    // McCamy's formula
495    let n = (x - 0.3320) / (0.1858 - y);
496    let cct = 449.0 * n.powi(3) + 3525.0 * n.powi(2) + 6823.3 * n + 5524.3;
497
498    (cct, 0.0)
499}
500
501impl XYZ {
502    /// Convert XYZ to CIE L*a*b* using the given white point.
503    /// Uses precise CIE constants for continuity at the threshold.
504    pub fn to_lab(&self, wp: XYZ) -> Lab {
505        // CIE standard constants for continuity
506        const EPSILON: f32 = 216.0 / 24389.0; // ≈ 0.008856
507        const KAPPA: f32 = 24389.0 / 27.0; // ≈ 903.2963
508
509        let f = |t: f32| -> f32 {
510            if t > EPSILON {
511                t.powf(1.0 / 3.0)
512            } else {
513                (KAPPA * t + 16.0) / 116.0
514            }
515        };
516
517        let fx = f(self.x / wp.x);
518        let fy = f(self.y / wp.y);
519        let fz = f(self.z / wp.z);
520
521        Lab {
522            l: 116.0 * fy - 16.0,
523            a: 500.0 * (fx - fy),
524            b: 200.0 * (fy - fz),
525        }
526    }
527
528    /// Convert XYZ (D65) to linear sRGB, then apply gamma correction.
529    /// Returns clamped (r, g, b) values in [0, 255].
530    ///
531    /// **IMPORTANT**: This function assumes the input XYZ is referenced to D65.
532    /// If your XYZ values are based on a different illuminant (e.g., D50 from
533    /// an ICC profile), you must first use `chromatic_adaptation::bradford_adapt`
534    /// to convert to D65 before calling this method.
535    #[expect(
536        clippy::excessive_precision,
537        reason = "High precision is required for accurate sRGB gamut conversion"
538    )]
539    pub fn to_srgb(&self) -> (u8, u8, u8) {
540        // XYZ to linear sRGB matrix (IEC 61966-2-1, D65 reference)
541        let r_lin = 3.2404542 * self.x - 1.5371385 * self.y - 0.4985314 * self.z;
542        let g_lin = -0.9692660 * self.x + 1.8760108 * self.y + 0.0415560 * self.z;
543        let b_lin = 0.0556434 * self.x - 0.2040259 * self.y + 1.0572252 * self.z;
544
545        // Gamma correction (sRGB companding)
546        fn gamma(c: f32) -> f32 {
547            if c <= 0.0031308 {
548                12.92 * c
549            } else {
550                1.055 * c.powf(1.0 / 2.4) - 0.055
551            }
552        }
553
554        let r = (gamma(r_lin).clamp(0.0, 1.0) * 255.0).round() as u8;
555        let g = (gamma(g_lin).clamp(0.0, 1.0) * 255.0).round() as u8;
556        let b = (gamma(b_lin).clamp(0.0, 1.0) * 255.0).round() as u8;
557
558        (r, g, b)
559    }
560
561    /// A safer version of `to_srgb` that takes the current white point of the XYZ
562    /// and automatically performs Bradford chromatic adaptation to D65 if needed.
563    pub fn to_srgb_safe(&self, current_wp: XYZ) -> (u8, u8, u8) {
564        if (self.x - current_wp.x).abs() < 1e-5
565            && (self.y - current_wp.y).abs() < 1e-5
566            && (self.z - current_wp.z).abs() < 1e-5
567        {
568            // Already matched or specialized logic could go here
569        }
570
571        if current_wp == illuminant::D65 {
572            self.to_srgb()
573        } else {
574            let adapted = chromatic_adaptation::bradford_adapt(*self, current_wp, illuminant::D65);
575            adapted.to_srgb()
576        }
577    }
578
579    /// Convert XYZ (absolute, D65) to Jzazbz color space.
580    /// Jzazbz (Safdar et al., 2017) is designed for HDR and provides
581    /// excellent perceptual uniformity across the full luminance range.
582    ///
583    /// # Parameters
584    /// * `luminance_scale`: Scaling factor for absolute luminance (cd/m²).
585    ///   For SDR, typically 100.0 or 200.0.
586    #[expect(
587        clippy::excessive_precision,
588        reason = "Jzazbz conversion matrices depend on exact coefficients from the original paper for perceptual uniformity"
589    )]
590    pub fn to_jzazbz(&self, luminance_scale: f32) -> Jzazbz {
591        // Step 1: Absolute luminance scaling
592        let x = self.x * luminance_scale;
593        let y = self.y * luminance_scale;
594        let z = self.z * luminance_scale;
595
596        // Step 2: XYZ to LMS (M1 matrix from Safdar et al. 2017)
597        let l = 0.41478972 * x + 0.57999905 * y + 0.01464805 * z;
598        let m = -0.20151003 * x + 1.12064859 * y + 0.05310084 * z;
599        let s = -0.01660078 * x + 0.26480015 * y + 0.66847986 * z;
600
601        // Step 3: PQ transfer function (Perceptual Quantizer, ST 2084)
602        fn pq(v: f32) -> f32 {
603            let v_abs = (v.max(0.0) / 10000.0) as f64; // PQ normalized to 10,000 nits
604            let n = 2610.0 / 16384.0;
605            let p = 1.7 * (2523.0 / 32.0);
606            let c1 = 3424.0 / 4096.0;
607            let c2 = 2413.0 / 128.0;
608            let c3 = 2392.0 / 128.0;
609
610            let v_pow_n = v_abs.powf(n);
611            (((c1 + c2 * v_pow_n) / (1.0 + c3 * v_pow_n)).powf(p)) as f32
612        }
613
614        let lp = pq(l);
615        let mp = pq(m);
616        let sp = pq(s);
617
618        // Step 4: Izazbz
619        let iz = 0.5 * lp + 0.5 * mp;
620        let az = 3.524000 * lp - 4.066708 * mp + 0.542708 * sp;
621        let bz = 0.199076 * lp + 1.096799 * mp - 1.295875 * sp;
622
623        // Step 5: Jz (with white point offset for neutral alignment)
624        let d = -0.56;
625        let jz = ((1.0 + d) * iz) / (1.0 + d * iz) - 0.005605;
626
627        Jzazbz {
628            jz: jz.max(0.0),
629            az,
630            bz,
631        }
632    }
633
634    /// Calculate Tristimulus XYZ from spectral reflectance using ASTM E308 weighting factors.
635    /// Assumes D65/2° and 10nm sampling (380-780nm).
636    ///
637    /// # Notes
638    /// - ASTM E308 weighting factors already include the D65 SPD and normalization.
639    /// - Input reflectance should be in the range 0.0-1.0 (or 0-100%).
640    /// - Output Y=100 corresponds to a perfect diffuser.
641    pub fn from_reflectance_10nm(reflectance: &[f32; 41]) -> Self {
642        let (x, y, z) = reflectance
643            .iter()
644            .zip(weighting::WX_D65_2_10.iter())
645            .zip(weighting::WY_D65_2_10.iter())
646            .zip(weighting::WZ_D65_2_10.iter())
647            .fold(
648                (0.0f32, 0.0f32, 0.0f32),
649                |(x, y, z), (((r, wx), wy), wz)| (x + r * wx, y + r * wy, z + r * wz),
650            );
651
652        // ASTM E308 weights are pre-scaled so that sum(WY * R) = Y directly
653        // when R is in 0-1 range. Multiply by 100 to get standard Y=100 scale.
654        Self {
655            x: x * 100.0 / weighting::SUM_WY_D65_2_10,
656            y: y * 100.0 / weighting::SUM_WY_D65_2_10,
657            z: z * 100.0 / weighting::SUM_WY_D65_2_10,
658        }
659    }
660
661    /// Convert XYZ to CIE 1960 UCS (u, v) coordinates.
662    /// This is used for CCT calculation and CRI reference illuminant alignment.
663    pub fn to_uv_1960(&self) -> (f32, f32) {
664        let denom = self.x + 15.0 * self.y + 3.0 * self.z;
665        if denom.abs() < 1e-9 {
666            return (0.0, 0.0);
667        }
668        let u = (4.0 * self.x) / denom;
669        let v = (6.0 * self.y) / denom;
670        (u, v)
671    }
672
673    /// Convert XYZ to CIE 1964 (U*, V*, W*) color space.
674    /// `white_uv`: The (u, v) coordinates of the white point.
675    pub fn to_uvw_1964(&self, white_uv: (f32, f32)) -> (f32, f32, f32) {
676        let (u, v) = self.to_uv_1960();
677        let w_star = 25.0 * self.y.powf(1.0 / 3.0) - 17.0;
678        let u_star = 13.0 * w_star * (u - white_uv.0);
679        let v_star = 13.0 * w_star * (v - white_uv.1);
680        (u_star, v_star, w_star)
681    }
682}
683
684pub mod generation {
685    /// CIE Standard Illuminant A/D series generation.
686    ///
687    /// CIE S0, S1, S2 spectral power components for Daylight reconstruction (380nm-780nm, 10nm steps)
688    pub const S0: [f32; 41] = [
689        0.0, 0.0, 33.4, 37.4, 117.4, 117.8, 114.9, 115.9, 108.8, 109.3, 107.8, 104.8, 107.7, 104.4,
690        104.0, 100.0, 96.0, 95.1, 89.1, 90.5, 90.3, 88.4, 84.0, 85.1, 81.9, 82.6, 84.9, 81.3, 71.9,
691        74.3, 76.4, 63.3, 71.7, 77.0, 65.2, 47.7, 68.6, 65.0, 66.0, 61.0, 53.3,
692    ];
693    pub const S1: [f32; 41] = [
694        0.0, 0.0, -1.1, -0.5, -0.7, -1.2, -2.6, -2.9, -2.8, -4.5, -6.1, -7.6, -9.7, -11.7, -12.2,
695        -13.6, -12.0, -13.3, -12.9, -10.6, -11.6, -10.8, -8.1, -10.3, -11.0, -11.5, -10.8, -10.9,
696        -8.8, -7.3, -12.9, -15.8, -15.1, -12.2, -10.2, -8.6, -12.0, -14.6, -15.1, -14.9, -13.7,
697    ];
698    pub const S2: [f32; 41] = [
699        0.0, 0.0, -2.1, -1.9, -1.1, -2.2, -3.5, -3.5, -3.3, -2.0, -1.2, -1.1, -0.5, 0.2, 0.5, 2.1,
700        3.2, 4.1, 4.7, 5.1, 6.7, 7.3, 8.6, 9.8, 10.2, 14.9, 18.1, 15.9, 16.8, 24.2, 31.7, 15.3,
701        18.9, 21.2, 15.6, 8.3, 18.9, 14.6, 15.5, 15.4, 14.6,
702    ];
703
704    /// Generate Planckian radiator SPD (black-body) for a given CCT and wavelengths.
705    pub fn generate_planckian(cct: f32, wavelengths: &[f32]) -> Vec<f32> {
706        let c1 = 3.741771e-16;
707        let c2 = 1.4388e-2;
708        wavelengths
709            .iter()
710            .map(|&wl| {
711                let wl_m = wl * 1e-9;
712                if wl_m == 0.0 {
713                    0.0
714                } else {
715                    c1 * wl_m.powi(-5) / ((c2 / (wl_m * cct)).exp() - 1.0)
716                }
717            })
718            .collect()
719    }
720
721    /// Generate CIE Daylight SPD for a given CCT and wavelengths.
722    pub fn generate_daylight(cct: f32, wavelengths: &[f32]) -> Vec<f32> {
723        let x_d = if cct <= 7000.0 {
724            -4.6070e9 / cct.powi(3) + 2.9678e6 / cct.powi(2) + 0.09911e3 / cct + 0.244063
725        } else {
726            -2.0064e9 / cct.powi(3) + 1.9018e6 / cct.powi(2) + 0.24748e3 / cct + 0.237040
727        };
728
729        let y_d = -3.000 * x_d * x_d + 2.870 * x_d - 0.275;
730
731        let m1 = (-1.3515 - 1.7703 * x_d + 5.9114 * y_d) / (0.0241 + 0.2562 * x_d - 0.7341 * y_d);
732        let m2 = (0.0300 - 31.4424 * x_d + 30.0717 * y_d) / (0.0241 + 0.2562 * x_d - 0.7341 * y_d);
733
734        wavelengths
735            .iter()
736            .map(|&wl| {
737                // Interpolate S0, S1, S2 from 10nm table (380-780, indices 0-40)
738                let t = (wl - 380.0) / 10.0;
739                let idx = t.floor() as i32;
740                let x = t - idx as f32;
741
742                let get_val = |table: &[f32; 41]| {
743                    if idx < 0 {
744                        table[0] // Clamp to 380nm value for < 380
745                    } else if idx >= 40 {
746                        table[40] // Clamp to 780nm value for > 780
747                    } else {
748                        let v0 = table[idx as usize];
749                        let v1 = table[(idx + 1) as usize];
750                        v0 + x * (v1 - v0)
751                    }
752                };
753
754                let s0 = get_val(&S0);
755                let s1 = get_val(&S1);
756                let s2 = get_val(&S2);
757
758                let val = s0 + m1 * s1 + m2 * s2;
759                if val < 0.0 { 0.0 } else { val }
760            })
761            .collect()
762    }
763}
764
765impl Lab {
766    /// Convert Lab back to XYZ using the given white point.
767    /// Uses precise CIE constants for continuity at the threshold.
768    pub fn to_xyz(&self, wp: XYZ) -> XYZ {
769        // CIE standard constants for continuity
770        const EPSILON: f32 = 216.0 / 24389.0; // ≈ 0.008856
771        const KAPPA: f32 = 24389.0 / 27.0; // ≈ 903.2963
772
773        let fy = (self.l + 16.0) / 116.0;
774        let fx = self.a / 500.0 + fy;
775        let fz = fy - self.b / 200.0;
776
777        let f_inv = |t: f32| -> f32 {
778            let t3 = t.powi(3);
779            if t3 > EPSILON {
780                t3
781            } else {
782                (116.0 * t - 16.0) / KAPPA
783            }
784        };
785
786        XYZ {
787            x: wp.x * f_inv(fx),
788            y: wp.y * f_inv(fy),
789            z: wp.z * f_inv(fz),
790        }
791    }
792
793    /// Convert Lab to sRGB via XYZ (using D65 white point).
794    /// Returns clamped (r, g, b) values in [0, 255].
795    pub fn to_srgb(&self) -> (u8, u8, u8) {
796        self.to_xyz(illuminant::D65_2).to_srgb()
797    }
798
799    /// Calculates Delta E*ab (CIE 1976).
800    pub fn delta_e_76(&self, other: &Lab) -> f32 {
801        ((self.l - other.l).powi(2) + (self.a - other.a).powi(2) + (self.b - other.b).powi(2))
802            .sqrt()
803    }
804
805    /// Calculates Delta E*00 (CIE 2000) using the Sharma (2005) reference implementation.
806    /// This is the industry standard for perceptual color difference.
807    pub fn delta_e_2000(&self, other: &Lab) -> f32 {
808        // Weight factors (default = 1.0)
809        let k_l = 1.0;
810        let k_c = 1.0;
811        let k_h = 1.0;
812
813        let c1 = (self.a.powi(2) + self.b.powi(2)).sqrt();
814        let c2 = (other.a.powi(2) + other.b.powi(2)).sqrt();
815        let avg_c = (c1 + c2) / 2.0;
816
817        // Calculate G and adjusted a'
818        let g = 0.5 * (1.0 - (avg_c.powi(7) / (avg_c.powi(7) + 25.0f32.powi(7))).sqrt());
819        let a1p = (1.0 + g) * self.a;
820        let a2p = (1.0 + g) * other.a;
821
822        let c1p = (a1p.powi(2) + self.b.powi(2)).sqrt();
823        let c2p = (a2p.powi(2) + other.b.powi(2)).sqrt();
824
825        // Key fix: atan2(b, a') - correct parameter order
826        let get_hp = |b: f32, ap: f32| -> f32 {
827            if b == 0.0 && ap == 0.0 {
828                0.0
829            } else {
830                let h = b.atan2(ap).to_degrees();
831                if h < 0.0 { h + 360.0 } else { h }
832            }
833        };
834        let h1p = get_hp(self.b, a1p);
835        let h2p = get_hp(other.b, a2p);
836
837        // Calculate delta values
838        let d_lp = other.l - self.l;
839        let d_cp = c2p - c1p;
840
841        let mut d_hp_deg = h2p - h1p;
842        if c1p * c2p != 0.0 {
843            if d_hp_deg.abs() > 180.0 {
844                if h2p <= h1p {
845                    d_hp_deg += 360.0;
846                } else {
847                    d_hp_deg -= 360.0;
848                }
849            }
850        } else {
851            d_hp_deg = 0.0;
852        }
853        let d_hp = 2.0 * (c1p * c2p).sqrt() * (d_hp_deg / 2.0).to_radians().sin();
854
855        // Calculate averages
856        let avg_lp = (self.l + other.l) / 2.0;
857        let avg_cp = (c1p + c2p) / 2.0;
858
859        let mut avg_hp = h1p + h2p;
860        if c1p * c2p != 0.0 {
861            if (h1p - h2p).abs() > 180.0 {
862                if h1p + h2p < 360.0 {
863                    avg_hp += 360.0;
864                } else {
865                    avg_hp -= 360.0;
866                }
867            }
868            avg_hp /= 2.0;
869        } else {
870            avg_hp = h1p + h2p;
871        }
872
873        // T term
874        let t = 1.0 - 0.17 * (avg_hp - 30.0).to_radians().cos()
875            + 0.24 * (2.0 * avg_hp).to_radians().cos()
876            + 0.32 * (3.0 * avg_hp + 6.0).to_radians().cos()
877            - 0.20 * (4.0 * avg_hp - 63.0).to_radians().cos();
878
879        // Key fix: SL denominator structure
880        let s_l = 1.0 + (0.015 * (avg_lp - 50.0).powi(2)) / (20.0 + (avg_lp - 50.0).powi(2)).sqrt();
881        let s_c = 1.0 + 0.045 * avg_cp;
882        let s_h = 1.0 + 0.015 * avg_cp * t;
883
884        // Rotation term RT
885        let d_theta = 30.0 * (-((avg_hp - 275.0) / 25.0).powi(2)).exp();
886        let rc = 2.0 * (avg_cp.powi(7) / (avg_cp.powi(7) + 25.0f32.powi(7))).sqrt();
887        let rt = -rc * (2.0 * d_theta.to_radians()).sin();
888
889        // Final Delta E 00
890        ((d_lp / (k_l * s_l)).powi(2)
891            + (d_cp / (k_c * s_c)).powi(2)
892            + (d_hp / (k_h * s_h)).powi(2)
893            + rt * (d_cp / (k_c * s_c)) * (d_hp / (k_h * s_h)))
894            .sqrt()
895    }
896
897    /// Mix two Lab colors by a given ratio (0.0 = self, 1.0 = other).
898    pub fn mix(&self, other: &Lab, ratio: f32) -> Lab {
899        let ratio = ratio.clamp(0.0, 1.0);
900        Lab {
901            l: self.l * (1.0 - ratio) + other.l * ratio,
902            a: self.a * (1.0 - ratio) + other.a * ratio,
903            b: self.b * (1.0 - ratio) + other.b * ratio,
904        }
905    }
906
907    /// Calculate chroma (C*) from a* and b*.
908    pub fn chroma(&self) -> f32 {
909        (self.a.powi(2) + self.b.powi(2)).sqrt()
910    }
911
912    /// Calculate hue angle (h°) in degrees [0, 360).
913    pub fn hue(&self) -> f32 {
914        let h = self.b.atan2(self.a).to_degrees();
915        if h < 0.0 { h + 360.0 } else { h }
916    }
917}
918
919impl Jzazbz {
920    /// Calculate Delta Ez (Jzazbz Euclidean distance).
921    /// Unlike CIEDE2000, this is a simple Euclidean distance that
922    /// directly represents perceptual difference due to Jzazbz's
923    /// excellent uniformity. Simpler and faster than Delta E 2000.
924    pub fn delta_ez(&self, other: &Jzazbz) -> f32 {
925        ((self.jz - other.jz).powi(2) + (self.az - other.az).powi(2) + (self.bz - other.bz).powi(2))
926            .sqrt()
927    }
928
929    /// Calculate chroma (Cz) in Jzazbz space.
930    pub fn chroma(&self) -> f32 {
931        (self.az.powi(2) + self.bz.powi(2)).sqrt()
932    }
933
934    /// Calculate hue angle (hz) in degrees [0, 360).
935    pub fn hue(&self) -> f32 {
936        let h = self.bz.atan2(self.az).to_degrees();
937        if h < 0.0 { h + 360.0 } else { h }
938    }
939
940    /// Mix two Jzazbz colors by a given ratio.
941    pub fn mix(&self, other: &Jzazbz, ratio: f32) -> Jzazbz {
942        let ratio = ratio.clamp(0.0, 1.0);
943        Jzazbz {
944            jz: self.jz * (1.0 - ratio) + other.jz * ratio,
945            az: self.az * (1.0 - ratio) + other.az * ratio,
946            bz: self.bz * (1.0 - ratio) + other.bz * ratio,
947        }
948    }
949}
950
951/// Color Rendering Index (CRI) and other light quality metrics.
952pub mod metrics {
953    use super::*;
954    use crate::spectrum::SpectralData;
955
956    /// Test Color Samples (TCS) for CRI calculation (380-780nm, 10nm).
957    /// Only including TCS01-TCS08 (for Ra) and TCS09 (for R9).
958    #[rustfmt::skip]
959    pub const TCS: [[f32; 41]; 9] = [
960        // TCS01: Light greyish red
961        [0.22, 0.25, 0.26, 0.25, 0.24, 0.24, 0.23, 0.23, 0.22, 0.22, 0.21, 0.22, 0.22, 0.23, 0.23, 0.23, 0.24, 0.25, 0.27, 0.30, 0.34, 0.39, 0.42, 0.44, 0.45, 0.45, 0.45, 0.45, 0.45, 0.45, 0.46, 0.46, 0.46, 0.46, 0.46, 0.47, 0.47, 0.47, 0.47, 0.47, 0.47],
962        // TCS02: Dark greyish yellow
963        [0.07, 0.09, 0.11, 0.12, 0.12, 0.12, 0.12, 0.12, 0.13, 0.13, 0.14, 0.15, 0.17, 0.21, 0.24, 0.26, 0.27, 0.27, 0.28, 0.30, 0.32, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.34, 0.33, 0.33, 0.33, 0.33, 0.33, 0.32, 0.32, 0.32, 0.32, 0.32, 0.31, 0.31],
964        // TCS03: Strong yellow green
965        [0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.07, 0.08, 0.09, 0.11, 0.15, 0.20, 0.24, 0.28, 0.34, 0.39, 0.40, 0.38, 0.35, 0.30, 0.26, 0.25, 0.24, 0.22, 0.22, 0.22, 0.23, 0.25, 0.29, 0.34, 0.39, 0.43, 0.46, 0.48, 0.49, 0.50, 0.51, 0.52, 0.52, 0.53, 0.54],
966        // TCS04: Moderate yellowish green
967        [0.07, 0.09, 0.12, 0.12, 0.13, 0.14, 0.14, 0.16, 0.19, 0.23, 0.28, 0.33, 0.37, 0.39, 0.39, 0.38, 0.34, 0.31, 0.28, 0.25, 0.21, 0.18, 0.16, 0.16, 0.15, 0.15, 0.15, 0.15, 0.16, 0.17, 0.17, 0.17, 0.17, 0.17, 0.18, 0.19, 0.19, 0.20, 0.21, 0.23, 0.25],
968        // TCS05: Light bluish green
969        [0.30, 0.31, 0.31, 0.32, 0.33, 0.34, 0.36, 0.38, 0.40, 0.42, 0.42, 0.41, 0.40, 0.39, 0.37, 0.35, 0.31, 0.28, 0.25, 0.22, 0.19, 0.19, 0.18, 0.18, 0.18, 0.18, 0.18, 0.19, 0.19, 0.20, 0.20, 0.20, 0.20, 0.20, 0.21, 0.21, 0.22, 0.22, 0.23, 0.24, 0.27],
970        // TCS06: Light blue
971        [0.15, 0.27, 0.41, 0.49, 0.52, 0.53, 0.54, 0.56, 0.55, 0.54, 0.52, 0.49, 0.45, 0.41, 0.36, 0.31, 0.25, 0.23, 0.23, 0.22, 0.22, 0.22, 0.22, 0.23, 0.24, 0.26, 0.27, 0.28, 0.28, 0.29, 0.30, 0.33, 0.35, 0.38, 0.40, 0.43, 0.45, 0.47, 0.49, 0.51, 0.53],
972        // TCS07: Light violet
973        [0.38, 0.52, 0.55, 0.56, 0.56, 0.56, 0.54, 0.51, 0.47, 0.43, 0.39, 0.34, 0.31, 0.30, 0.27, 0.26, 0.26, 0.26, 0.26, 0.25, 0.26, 0.28, 0.32, 0.36, 0.39, 0.41, 0.43, 0.44, 0.45, 0.47, 0.47, 0.48, 0.49, 0.50, 0.51, 0.53, 0.54, 0.55, 0.57, 0.58, 0.59],
974        // TCS08: Light reddish purple
975        [0.10, 0.17, 0.32, 0.46, 0.49, 0.48, 0.45, 0.43, 0.40, 0.37, 0.34, 0.31, 0.29, 0.28, 0.26, 0.25, 0.26, 0.27, 0.27, 0.28, 0.32, 0.38, 0.48, 0.57, 0.63, 0.66, 0.69, 0.70, 0.71, 0.71, 0.72, 0.72, 0.72, 0.72, 0.73, 0.73, 0.73, 0.73, 0.73, 0.73, 0.73],
976        // TCS09: Strong red (R9)
977        [0.066, 0.058, 0.052, 0.051, 0.050, 0.048, 0.046, 0.041, 0.035, 0.030, 0.028, 0.028, 0.030, 0.031, 0.032, 0.033, 0.041, 0.048, 0.060, 0.102, 0.190, 0.336, 0.505, 0.641, 0.717, 0.758, 0.781, 0.797, 0.809, 0.819, 0.828, 0.831, 0.835, 0.836, 0.838, 0.839, 0.839, 0.839, 0.839, 0.839, 0.838],
978    ];
979
980    /// Calculate CRI Ra and R9 for a given spectral power distribution.
981    pub fn calculate_cri(spd: &SpectralData) -> (f32, f32) {
982        let xyz = spd.to_xyz_emissive_2();
983        let cct = xyz.to_cct();
984
985        // 1. Generate reference SPD
986        let ref_spd = if cct < 5000.0 {
987            generate_planckian(cct)
988        } else {
989            generate_daylight(cct)
990        };
991
992        // 2. Calculate Ri for each TCS
993        let mut ris = [0.0f32; 9];
994        for i in 0..9 {
995            ris[i] = calculate_ri(spd, &ref_spd, &TCS[i]);
996        }
997
998        // Ra is average of first 8
999        let ra = ris[0..8].iter().sum::<f32>() / 8.0;
1000        let r9 = ris[8];
1001
1002        (ra, r9)
1003    }
1004
1005    fn calculate_ri(test_spd: &SpectralData, ref_spd: &[f32; 41], tcs: &[f32; 41]) -> f32 {
1006        let (xb, yb, zb) = Observer::CIE1931_2.get_cmfs();
1007
1008        let calc_xyz = |spd_vals: &[f32], tcs_vals: &[f32]| -> XYZ {
1009            let mut x = 0.0;
1010            let mut y = 0.0;
1011            let mut z = 0.0;
1012            for i in 0..41 {
1013                let val = spd_vals[i] * tcs_vals[i];
1014                x += val * xb[i];
1015                y += val * yb[i];
1016                z += val * zb[i];
1017            }
1018            XYZ { x, y, z }
1019        };
1020
1021        let test_xyz = calc_xyz(&test_spd.values, tcs);
1022        let ref_xyz = calc_xyz(ref_spd, tcs);
1023
1024        // Simplified CRI calculation (skipping full Von Kries for brevity,
1025        // but using Lab Delta E as a proxy for Ri)
1026        // Ri = 100 - 4.6 * Delta E
1027        let test_lab = test_xyz.to_lab(test_spd.to_xyz_emissive_2());
1028
1029        let mut ref_white_x = 0.0;
1030        let mut ref_white_y = 0.0;
1031        let mut ref_white_z = 0.0;
1032        for i in 0..41 {
1033            ref_white_x += ref_spd[i] * xb[i];
1034            ref_white_y += ref_spd[i] * yb[i];
1035            ref_white_z += ref_spd[i] * zb[i];
1036        }
1037        let ref_white = XYZ {
1038            x: ref_white_x,
1039            y: ref_white_y,
1040            z: ref_white_z,
1041        };
1042        let ref_lab = ref_xyz.to_lab(ref_white);
1043
1044        let de = test_lab.delta_e_76(&ref_lab);
1045        100.0 - 4.6 * de
1046    }
1047
1048    fn generate_planckian(cct: f32) -> [f32; 41] {
1049        let mut spd = [0.0f32; 41];
1050        let c1 = 3.741771e-16_f32;
1051        let c2 = 1.4388e-2_f32;
1052        for (i, val) in spd.iter_mut().enumerate() {
1053            let wl = (380 + i * 10) as f32 * 1e-9_f32;
1054            *val = c1 / (wl.powi(5) * ((c2 / (wl * cct)).exp() - 1.0));
1055        }
1056        spd
1057    }
1058
1059    fn generate_daylight(cct: f32) -> [f32; 41] {
1060        // Simplified D-series generator
1061        let xd = if cct <= 7000.0 {
1062            -4.6070e9 / cct.powi(3) + 2.9678e6 / cct.powi(2) + 0.09911e3 / cct + 0.244063
1063        } else {
1064            -2.0064e9 / cct.powi(3) + 1.9018e6 / cct.powi(2) + 0.24748e3 / cct + 0.237040
1065        };
1066
1067        let yd = -3.000 * xd * xd + 2.870 * xd - 0.275;
1068
1069        let m1 = (-1.3515 - 1.7703 * xd + 5.9114 * yd) / (0.0241 + 0.2562 * xd - 0.7341 * yd);
1070        let m2 = (0.0300 - 31.4424 * xd + 30.0717 * yd) / (0.0241 + 0.2562 * xd - 0.7341 * yd);
1071
1072        let mut spd = [0.0f32; 41];
1073        for (i, val) in spd.iter_mut().enumerate() {
1074            // Using D65 as a base and applying M1, M2 (simplified)
1075            *val = illuminant::spd::D65[i] * (1.0 + m1 * 0.01 + m2 * 0.01);
1076        }
1077        spd
1078    }
1079}
1080
1081/// Color appearance and analysis utilities.
1082pub mod appearance {
1083    use super::{Lab, XYZ, illuminant};
1084    use crate::spectrum::SpectralData;
1085
1086    /// Calculate Metamerism Index between two spectral samples.
1087    /// Compares how differently the samples appear under a test illuminant
1088    /// relative to a reference illuminant (typically D65).
1089    ///
1090    /// A high metamerism index means the samples look similar under one illuminant
1091    /// but different under another — a common issue in color matching.
1092    pub fn metamerism_index(
1093        sample1: &SpectralData,
1094        sample2: &SpectralData,
1095        ref_illuminant: XYZ,
1096        test_illuminant: XYZ,
1097    ) -> f32 {
1098        // Calculate Lab under reference illuminant
1099        let xyz1_ref = sample1.to_xyz();
1100        let xyz2_ref = sample2.to_xyz();
1101        let lab1_ref = XYZ {
1102            x: xyz1_ref.x / 100.0,
1103            y: xyz1_ref.y / 100.0,
1104            z: xyz1_ref.z / 100.0,
1105        }
1106        .to_lab(ref_illuminant);
1107        let lab2_ref = XYZ {
1108            x: xyz2_ref.x / 100.0,
1109            y: xyz2_ref.y / 100.0,
1110            z: xyz2_ref.z / 100.0,
1111        }
1112        .to_lab(ref_illuminant);
1113
1114        // Adapt XYZ to test illuminant using Bradford
1115        let xyz1_test =
1116            super::chromatic_adaptation::bradford_adapt(xyz1_ref, illuminant::D65, test_illuminant);
1117        let xyz2_test =
1118            super::chromatic_adaptation::bradford_adapt(xyz2_ref, illuminant::D65, test_illuminant);
1119
1120        let lab1_test = XYZ {
1121            x: xyz1_test.x / 100.0,
1122            y: xyz1_test.y / 100.0,
1123            z: xyz1_test.z / 100.0,
1124        }
1125        .to_lab(test_illuminant);
1126        let lab2_test = XYZ {
1127            x: xyz2_test.x / 100.0,
1128            y: xyz2_test.y / 100.0,
1129            z: xyz2_test.z / 100.0,
1130        }
1131        .to_lab(test_illuminant);
1132
1133        // Delta E under reference
1134        let de_ref = lab1_ref.delta_e_2000(&lab2_ref);
1135        // Delta E under test
1136        let de_test = lab1_test.delta_e_2000(&lab2_test);
1137
1138        // Metamerism index is the difference in color difference
1139        (de_test - de_ref).abs()
1140    }
1141
1142    /// Simulate how a color appears under a different illuminant.
1143    /// Uses Bradford chromatic adaptation.
1144    pub fn simulate_illuminant(lab: &Lab, from: XYZ, to: XYZ) -> Lab {
1145        let xyz = lab.to_xyz(from);
1146        let adapted = super::chromatic_adaptation::bradford_adapt(xyz, from, to);
1147        adapted.to_lab(to)
1148    }
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153    use super::*;
1154
1155    #[test]
1156    fn test_d65_white_point_from_weighting() {
1157        // Perfect diffuser (100% reflectance across all wavelengths)
1158        let reflectance = [1.0f32; 41];
1159        let xyz = XYZ::from_reflectance_10nm(&reflectance);
1160
1161        // Expected D65 white point (2-degree)
1162        // X = 95.047
1163        // Y = 100.000
1164        // Z = 108.883
1165        assert!(
1166            (xyz.y - 100.0).abs() < 0.05,
1167            "Y should be 100, got {}",
1168            xyz.y
1169        );
1170        assert!(
1171            (xyz.x - 95.05).abs() < 0.05,
1172            "X should be ~95.05, got {}",
1173            xyz.x
1174        );
1175        assert!(
1176            (xyz.z - 108.88).abs() < 0.05,
1177            "Z should be ~108.88, got {}",
1178            xyz.z
1179        );
1180    }
1181
1182    #[test]
1183    fn test_xyz_to_lab_d65() {
1184        let white = illuminant::D65;
1185        let lab = white.to_lab(white);
1186
1187        // Lab of the white point itself should be (100, 0, 0)
1188        assert!((lab.l - 100.0).abs() < 1e-4);
1189        assert!(lab.a.abs() < 1e-4);
1190        assert!(lab.b.abs() < 1e-4);
1191    }
1192}