Skip to main content

qualia_core_db/render/
spectral.rs

1//! σ → CIE 1931 XYZ → linear sRGB — CPU oracle for canvas2d fallback and cold-path export.
2//!
3//! WGSL twin: `shaders/viewport/spectral.wgsl`. HDR GPU path stays linear; display paths
4//! apply sRGB gamma encode via `sigma_to_display_rgb`.
5
6#[inline]
7fn fract_sigma(sigma: f32) -> f32 {
8    sigma - sigma.floor()
9}
10
11#[inline]
12pub fn sigma_to_cie_xyz(sigma: f32) -> [f32; 3] {
13    let s = fract_sigma(sigma);
14    let lambda = 400.0 + s * 300.0;
15
16    let gauss = |lambda: f32, center: f32, width: f32| -> f32 {
17        let d = (lambda - center) / width;
18        (-0.5 * d * d).exp()
19    };
20
21    let x1 = 1.056 * gauss(lambda, 599.8, 43.2);
22    let x2 = 0.362 * gauss(lambda, 442.0, 32.0);
23    let x3 = -0.065 * gauss(lambda, 501.1, 20.4);
24    let x = x1 + x2 + x3;
25
26    let y1 = 0.821 * gauss(lambda, 568.8, 46.9);
27    let y2 = 0.286 * gauss(lambda, 530.9, 16.3);
28    let y = y1 + y2;
29
30    let z1 = 1.217 * gauss(lambda, 437.0, 11.8);
31    let z2 = 0.681 * gauss(lambda, 459.0, 26.0);
32    let z = z1 + z2;
33
34    [x, y, z]
35}
36
37#[inline]
38pub fn xyz_to_linear_srgb(xyz: [f32; 3]) -> [f32; 3] {
39    let r = 3.2404542 * xyz[0] - 1.5371385 * xyz[1] - 0.4985314 * xyz[2];
40    let g = -0.9692660 * xyz[0] + 1.8760108 * xyz[1] + 0.0415560 * xyz[2];
41    let b = 0.0556434 * xyz[0] - 0.2040259 * xyz[1] + 1.0572252 * xyz[2];
42    [r.max(0.0), g.max(0.0), b.max(0.0)]
43}
44
45#[inline]
46pub fn sigma_to_linear_rgb(sigma: f32) -> [f32; 3] {
47    xyz_to_linear_srgb(sigma_to_cie_xyz(sigma))
48}
49
50#[inline]
51fn linear_to_srgb_channel(c: f32) -> f32 {
52    if c <= 0.0031308 {
53        12.92 * c
54    } else {
55        1.055 * c.powf(1.0 / 2.4) - 0.055
56    }
57}
58
59/// sRGB 8-bit for canvas2d / HUD (gamma-encoded display).
60#[inline]
61pub fn sigma_to_display_rgb(sigma: f32) -> (u8, u8, u8) {
62    let linear = sigma_to_linear_rgb(sigma);
63    let scale = 1.0 / linear.iter().copied().fold(0.0_f32, f32::max).max(1e-6);
64    let norm = [linear[0] * scale, linear[1] * scale, linear[2] * scale];
65    (
66        (linear_to_srgb_channel(norm[0].min(1.0)) * 255.0).round() as u8,
67        (linear_to_srgb_channel(norm[1].min(1.0)) * 255.0).round() as u8,
68        (linear_to_srgb_channel(norm[2].min(1.0)) * 255.0).round() as u8,
69    )
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn same_sigma_same_linear_rgb() {
78        let a = sigma_to_linear_rgb(0.42);
79        let b = sigma_to_linear_rgb(0.42);
80        assert_eq!(a, b);
81    }
82
83    #[test]
84    fn sigma_wraps_via_fract() {
85        let a = sigma_to_linear_rgb(0.25);
86        let b = sigma_to_linear_rgb(1.25);
87        assert_eq!(a, b);
88    }
89
90    #[test]
91    fn green_band_dominates_mid_sigma() {
92        let rgb = sigma_to_linear_rgb(0.5);
93        assert!(rgb[1] >= rgb[0]);
94        assert!(rgb[1] >= rgb[2]);
95    }
96
97    #[test]
98    fn linear_components_non_negative() {
99        for i in 0..=10 {
100            let s = i as f32 / 10.0;
101            let rgb = sigma_to_linear_rgb(s);
102            assert!(rgb[0] >= 0.0 && rgb[1] >= 0.0 && rgb[2] >= 0.0);
103        }
104    }
105}