1use bytemuck::{Pod, Zeroable};
37
38pub const SPD_SAMPLES: usize = 41;
44
45pub const LAMBDA_MIN: f32 = 380.0;
47
48pub const LAMBDA_MAX: f32 = 780.0;
50
51pub const LAMBDA_STEP: f32 = 10.0;
53
54pub const CIE_1931_CMF_X: [f32; SPD_SAMPLES] = [
60 0.001368, 0.004243, 0.014310, 0.043510, 0.134380, 0.283900, 0.348280, 0.336200, 0.290800,
61 0.195360, 0.095640, 0.032010, 0.004900, 0.009300, 0.063270, 0.165500, 0.290400, 0.433450,
62 0.594500, 0.762100, 0.916300, 1.026300, 1.062200, 1.002600, 0.854450, 0.642400, 0.447900,
63 0.283500, 0.164900, 0.087400, 0.046770, 0.022700, 0.011359, 0.005790, 0.002899, 0.001440,
64 0.000690, 0.000332, 0.000166, 0.000083, 0.000042,
65];
66
67pub const CIE_1931_CMF_Y: [f32; SPD_SAMPLES] = [
68 0.000039, 0.000120, 0.000396, 0.001210, 0.004000, 0.011600, 0.023000, 0.038000, 0.060000,
69 0.090980, 0.139020, 0.208020, 0.323000, 0.503000, 0.710000, 0.862000, 0.954000, 0.994950,
70 0.995000, 0.952000, 0.870000, 0.757000, 0.631000, 0.503000, 0.381000, 0.265000, 0.175000,
71 0.107000, 0.061000, 0.032000, 0.017000, 0.008210, 0.004102, 0.002091, 0.001047, 0.000520,
72 0.000249, 0.000120, 0.000060, 0.000030, 0.000015,
73];
74
75pub const CIE_1931_CMF_Z: [f32; SPD_SAMPLES] = [
76 0.006450, 0.020050, 0.067850, 0.207400, 0.645600, 1.282500, 1.453000, 1.562100, 1.562700,
77 1.385600, 1.114600, 0.777500, 0.445600, 0.198700, 0.068100, 0.019800, 0.004100, 0.000500,
78 0.000200, 0.000010, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
79 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
80 0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
81];
82
83pub const CIE_D65_X: f32 = 0.95047;
85pub const CIE_D65_Y: f32 = 1.00000;
86pub const CIE_D65_Z: f32 = 1.08883;
87
88#[repr(C)]
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct Spd {
99 pub samples: [f32; SPD_SAMPLES],
101}
102
103unsafe impl Zeroable for Spd {}
105unsafe impl Pod for Spd {}
106
107impl Default for Spd {
108 #[inline]
109 fn default() -> Self {
110 Self {
111 samples: [0.0; SPD_SAMPLES],
112 }
113 }
114}
115
116impl Spd {
117 #[inline]
119 pub fn from_samples(samples: [f32; SPD_SAMPLES]) -> Self {
120 Self { samples }
121 }
122
123 #[inline]
125 pub fn flat(value: f32) -> Self {
126 Self {
127 samples: [value; SPD_SAMPLES],
128 }
129 }
130
131 #[inline]
134 pub fn delta(lambda_nm: f32) -> Self {
135 let mut spd = Self::default();
136 if lambda_nm < LAMBDA_MIN || lambda_nm > LAMBDA_MAX {
137 return spd;
138 }
139 let idx = ((lambda_nm - LAMBDA_MIN) / LAMBDA_STEP).round() as usize;
140 let idx = idx.min(SPD_SAMPLES - 1);
141 spd.samples[idx] = 1.0;
142 spd
143 }
144
145 #[inline]
148 pub fn gaussian_peak(lambda_nm: f32, width_nm: f32, amplitude: f32) -> Self {
149 let mut spd = Self::default();
150 for i in 0..SPD_SAMPLES {
151 let lambda = LAMBDA_MIN + i as f32 * LAMBDA_STEP;
152 let d = (lambda - lambda_nm) / width_nm;
153 spd.samples[i] = amplitude * (-0.5 * d * d).exp();
154 }
155 spd
156 }
157
158 #[inline]
160 pub fn scale(&self, s: f32) -> Self {
161 let mut out = *self;
162 for v in &mut out.samples {
163 *v *= s;
164 }
165 out
166 }
167
168 #[inline]
170 pub fn add(&self, other: &Self) -> Self {
171 let mut out = *self;
172 for i in 0..SPD_SAMPLES {
173 out.samples[i] += other.samples[i];
174 }
175 out
176 }
177
178 #[inline]
180 pub fn lerp(&self, other: &Self, t: f32) -> Self {
181 let mut out = Self::default();
182 for i in 0..SPD_SAMPLES {
183 out.samples[i] = self.samples[i] * (1.0 - t) + other.samples[i] * t;
184 }
185 out
186 }
187
188 #[inline]
190 pub fn total_power(&self) -> f32 {
191 self.samples.iter().copied().sum()
192 }
193}
194
195#[repr(C)]
197#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
198pub struct Xyz {
199 pub x: f32,
200 pub y: f32,
201 pub z: f32,
202}
203
204impl Xyz {
205 #[inline]
206 pub fn new(x: f32, y: f32, z: f32) -> Self {
207 Self { x, y, z }
208 }
209}
210
211#[repr(C)]
213#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
214pub struct LinearRgb {
215 pub r: f32,
216 pub g: f32,
217 pub b: f32,
218}
219
220impl LinearRgb {
221 #[inline]
222 pub fn new(r: f32, g: f32, b: f32) -> Self {
223 Self { r, g, b }
224 }
225}
226
227#[inline]
243pub fn emf_to_spd(alpha: f32, mu: f32, sigma: f32) -> Spd {
244 let lambda = 400.0 + sigma.clamp(0.0, 1.0) * 300.0;
245 let width = 10.0 + mu.clamp(0.0, 1.0) * 140.0;
247 let amplitude = alpha.max(0.0);
248
249 Spd::gaussian_peak(lambda, width, amplitude)
250}
251
252#[inline]
263pub fn spd_to_xyz(spd: &Spd) -> Xyz {
264 let mut x = 0.0f32;
265 let mut y = 0.0f32;
266 let mut z = 0.0f32;
267
268 for i in 0..SPD_SAMPLES {
269 let s = spd.samples[i];
270 x += s * CIE_1931_CMF_X[i];
271 y += s * CIE_1931_CMF_Y[i];
272 z += s * CIE_1931_CMF_Z[i];
273 }
274
275 let norm = 1.0 / CIE_1931_CMF_Y.iter().copied().sum::<f32>();
278 Xyz::new(x * norm, y * norm, z * norm)
279}
280
281#[inline]
283pub fn flat_spd_to_xyz() -> Xyz {
284 let spd = Spd::flat(1.0);
285 spd_to_xyz(&spd)
286}
287
288#[inline]
294pub fn xyz_to_linear_srgb(xyz: &Xyz) -> LinearRgb {
295 let r = 3.2404542 * xyz.x - 1.5371385 * xyz.y - 0.4985314 * xyz.z;
296 let g = -0.9692660 * xyz.x + 1.8760108 * xyz.y + 0.0415560 * xyz.z;
297 let b = 0.0556434 * xyz.x - 0.2040259 * xyz.y + 1.0572252 * xyz.z;
298 LinearRgb::new(r.max(0.0), g.max(0.0), b.max(0.0))
299}
300
301#[inline]
303pub fn emf_to_linear_rgb(alpha: f32, mu: f32, sigma: f32) -> LinearRgb {
304 let spd = emf_to_spd(alpha, mu, sigma);
305 let xyz = spd_to_xyz(&spd);
306 xyz_to_linear_srgb(&xyz)
307}
308
309#[inline]
317pub fn delta_e_76(xyz1: &Xyz, xyz2: &Xyz) -> f32 {
318 let lab1 = xyz_to_lab(xyz1);
319 let lab2 = xyz_to_lab(xyz2);
320 let dl = lab1.0 - lab2.0;
321 let da = lab1.1 - lab2.1;
322 let db = lab1.2 - lab2.2;
323 (dl * dl + da * da + db * db).sqrt()
324}
325
326#[inline]
328pub fn xyz_to_lab(xyz: &Xyz) -> (f32, f32, f32) {
329 let xr = xyz.x / CIE_D65_X;
330 let yr = xyz.y / CIE_D65_Y;
331 let zr = xyz.z / CIE_D65_Z;
332
333 let f = |t: f32| -> f32 {
334 if t > 0.008856 {
335 t.cbrt()
336 } else {
337 7.787 * t + 16.0 / 116.0
338 }
339 };
340
341 let fx = f(xr);
342 let fy = f(yr);
343 let fz = f(zr);
344
345 let l = 116.0 * fy - 16.0;
346 let a = 500.0 * (fx - fy);
347 let b = 200.0 * (fy - fz);
348 (l, a, b)
349}
350
351#[inline]
360pub fn gaussian_vs_tabulated_delta_e(sigma: f32) -> f32 {
361 let spd = emf_to_spd(1.0, 0.0, sigma);
363 let xyz_tabulated = spd_to_xyz(&spd);
364
365 let s = sigma - sigma.floor();
367 let lambda = 400.0 + s * 300.0;
368 let gauss = |lambda: f32, center: f32, width: f32| -> f32 {
369 let d = (lambda - center) / width;
370 (-0.5 * d * d).exp()
371 };
372 let x = 1.056 * gauss(lambda, 599.8, 43.2) + 0.362 * gauss(lambda, 442.0, 32.0)
373 - 0.065 * gauss(lambda, 501.1, 20.4);
374 let y = 0.821 * gauss(lambda, 568.8, 46.9) + 0.286 * gauss(lambda, 530.9, 16.3);
375 let z = 1.217 * gauss(lambda, 437.0, 11.8) + 0.681 * gauss(lambda, 459.0, 26.0);
376 let xyz_gaussian = Xyz::new(x, y, z);
377
378 delta_e_76(&xyz_tabulated, &xyz_gaussian)
379}
380
381#[inline]
387pub fn linear_to_srgb_channel(c: f32) -> f32 {
388 if c <= 0.0031308 {
389 12.92 * c
390 } else {
391 1.055 * c.powf(1.0 / 2.4) - 0.055
392 }
393}
394
395#[inline]
397pub fn linear_rgb_to_display(rgb: &LinearRgb) -> (u8, u8, u8) {
398 let scale = 1.0 / rgb.r.max(rgb.g).max(rgb.b).max(1e-6);
399 let nr = (rgb.r * scale).min(1.0);
400 let ng = (rgb.g * scale).min(1.0);
401 let nb = (rgb.b * scale).min(1.0);
402 (
403 (linear_to_srgb_channel(nr) * 255.0).round() as u8,
404 (linear_to_srgb_channel(ng) * 255.0).round() as u8,
405 (linear_to_srgb_channel(nb) * 255.0).round() as u8,
406 )
407}
408
409#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn flat_spd_yields_equal_energy_white_point() {
419 let xyz = flat_spd_to_xyz();
422 assert!(
424 (xyz.y - 1.0).abs() < 0.01,
425 "Y should be ~1.0, got {}",
426 xyz.y
427 );
428 let sum_x: f32 = CIE_1931_CMF_X.iter().copied().sum();
430 let sum_y: f32 = CIE_1931_CMF_Y.iter().copied().sum();
431 let sum_z: f32 = CIE_1931_CMF_Z.iter().copied().sum();
432 let expected_x_ratio = sum_x / sum_y;
433 let expected_z_ratio = sum_z / sum_y;
434 assert!(
435 (xyz.x / xyz.y - expected_x_ratio).abs() < 0.01,
436 "X/Y ratio {} should match equal-energy {}",
437 xyz.x / xyz.y,
438 expected_x_ratio
439 );
440 assert!(
441 (xyz.z / xyz.y - expected_z_ratio).abs() < 0.01,
442 "Z/Y ratio {} should match equal-energy {}",
443 xyz.z / xyz.y,
444 expected_z_ratio
445 );
446 }
447
448 #[test]
449 fn single_lambda_delta_reproduces_cmf() {
450 let spd = Spd::delta(550.0);
452 let xyz = spd_to_xyz(&spd);
453 let idx = ((550.0 - LAMBDA_MIN) / LAMBDA_STEP).round() as usize;
455 let expected_x = CIE_1931_CMF_X[idx];
456 let expected_y = CIE_1931_CMF_Y[idx];
457 let expected_z = CIE_1931_CMF_Z[idx];
458 let norm = 1.0 / CIE_1931_CMF_Y.iter().copied().sum::<f32>();
460 assert!(
461 (xyz.x - expected_x * norm).abs() < 0.01,
462 "X should match CMF"
463 );
464 assert!(
465 (xyz.y - expected_y * norm).abs() < 0.01,
466 "Y should match CMF"
467 );
468 assert!(
469 (xyz.z - expected_z * norm).abs() < 0.01,
470 "Z should match CMF"
471 );
472 }
473
474 #[test]
475 fn spd_determinism() {
476 let spd1 = emf_to_spd(1.0, 0.3, 0.5);
477 let spd2 = emf_to_spd(1.0, 0.3, 0.5);
478 assert_eq!(spd1, spd2, "SPD must be deterministic");
479 }
480
481 #[test]
482 fn xyz_projection_determinism() {
483 let spd = emf_to_spd(0.8, 0.2, 0.6);
484 let xyz1 = spd_to_xyz(&spd);
485 let xyz2 = spd_to_xyz(&spd);
486 assert_eq!(xyz1, xyz2, "XYZ projection must be deterministic");
487 }
488
489 #[test]
490 fn emf_to_linear_rgb_determinism() {
491 let rgb1 = emf_to_linear_rgb(1.0, 0.3, 0.5);
492 let rgb2 = emf_to_linear_rgb(1.0, 0.3, 0.5);
493 assert_eq!(rgb1, rgb2, "EMF→RGB must be deterministic");
494 }
495
496 #[test]
497 fn green_band_dominates_mid_sigma() {
498 let rgb = emf_to_linear_rgb(1.0, 0.1, 0.5);
501 assert!(
502 rgb.g >= rgb.r,
503 "G should dominate R at σ=0.5: r={} g={}",
504 rgb.r,
505 rgb.g
506 );
507 assert!(
508 rgb.g >= rgb.b,
509 "G should dominate B at σ=0.5: g={} b={}",
510 rgb.g,
511 rgb.b
512 );
513 }
514
515 #[test]
516 fn blue_band_dominates_low_sigma() {
517 let rgb = emf_to_linear_rgb(1.0, 0.0, 0.0);
519 assert!(rgb.b >= rgb.r, "B should dominate R at σ=0.0");
520 assert!(rgb.b >= rgb.g, "B should dominate G at σ=0.0");
521 }
522
523 #[test]
524 fn red_band_dominates_high_sigma() {
525 let rgb = emf_to_linear_rgb(1.0, 0.1, 1.0);
528 assert!(
529 rgb.r >= rgb.g,
530 "R should dominate G at σ=1.0: r={} g={}",
531 rgb.r,
532 rgb.g
533 );
534 assert!(
535 rgb.r >= rgb.b,
536 "R should dominate B at σ=1.0: r={} b={}",
537 rgb.r,
538 rgb.b
539 );
540 }
541
542 #[test]
543 fn amplitude_scales_power() {
544 let spd_low = emf_to_spd(0.5, 0.0, 0.5);
545 let spd_high = emf_to_spd(1.0, 0.0, 0.5);
546 assert!(
547 spd_high.total_power() > spd_low.total_power(),
548 "higher α should produce more total power"
549 );
550 }
551
552 #[test]
553 fn mu_broadens_spectrum() {
554 let spd_narrow = emf_to_spd(1.0, 0.0, 0.5);
555 let spd_broad = emf_to_spd(1.0, 1.0, 0.5);
556 let narrow_nonzero = spd_narrow.samples.iter().filter(|&&v| v > 0.01).count();
558 let broad_nonzero = spd_broad.samples.iter().filter(|&&v| v > 0.01).count();
559 assert!(
560 broad_nonzero >= narrow_nonzero,
561 "broad μ should have >= non-zero samples: {} vs {}",
562 broad_nonzero,
563 narrow_nonzero
564 );
565 }
566
567 #[test]
568 fn spd_lerp_endpoints() {
569 let a = Spd::delta(450.0);
570 let b = Spd::delta(650.0);
571 let at_0 = a.lerp(&b, 0.0);
572 let at_1 = a.lerp(&b, 1.0);
573 assert_eq!(at_0, a, "lerp at t=0 should return first SPD");
574 assert_eq!(at_1, b, "lerp at t=1 should return second SPD");
575 }
576
577 #[test]
578 fn delta_e_self_is_zero() {
579 let xyz = spd_to_xyz(&emf_to_spd(1.0, 0.3, 0.5));
580 assert!(delta_e_76(&xyz, &xyz) < 1e-6, "ΔE to self should be ~0");
581 }
582
583 #[test]
584 fn gaussian_vs_tabulated_delta_e_finite() {
585 for i in 0..=10 {
586 let sigma = i as f32 / 10.0;
587 let de = gaussian_vs_tabulated_delta_e(sigma);
588 assert!(de.is_finite(), "ΔE must be finite at σ={}", sigma);
589 assert!(de >= 0.0, "ΔE must be non-negative at σ={}", sigma);
590 }
591 }
592
593 #[test]
594 fn xyz_to_lab_d65_white_is_l100() {
595 let white = Xyz::new(CIE_D65_X, CIE_D65_Y, CIE_D65_Z);
596 let (l, _a, _b) = xyz_to_lab(&white);
597 assert!(
598 (l - 100.0).abs() < 0.5,
599 "D65 white should have L≈100, got {}",
600 l
601 );
602 }
603
604 #[test]
605 fn display_rgb_in_range() {
606 for i in 0..=10 {
607 let sigma = i as f32 / 10.0;
608 let rgb = emf_to_linear_rgb(1.0, 0.0, sigma);
609 let (_r, _g, _b) = linear_rgb_to_display(&rgb);
610 }
612 }
613}