Skip to main content

brep_render/
color.rs

1//! Stable per-solid display colors + color-space helpers.
2//!
3//! `solid_color_srgb` is a byte-faithful port of the retired artifact renderer's
4//! `solidColor`: FNV-1a over the name's UTF-16 code
5//! units, hue = ((hash >>> 8) % 360)/360, HSL(hue, 0.45, 0.6). Keeping the hash
6//! EXACT means re-baselined artifacts keep the colors users already know.
7
8/// FNV-1a over UTF-16 code units with wrapping 32-bit multiplies — byte-compatible
9/// with the original implementation.
10fn fnv1a_utf16(name: &str) -> u32 {
11    let mut hash: u32 = 2166136261;
12    for unit in name.encode_utf16() {
13        hash ^= unit as u32;
14        hash = hash.wrapping_mul(16777619);
15    }
16    hash
17}
18
19/// HSL → sRGB, the exact formula the retired soft raster used.
20fn hsl_to_rgb(h: f64, s: f64, l: f64) -> [f64; 3] {
21    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
22    let p = 2.0 * l - q;
23    let channel = |t: f64| -> f64 {
24        let t = t.rem_euclid(1.0);
25        if t < 1.0 / 6.0 {
26            p + (q - p) * 6.0 * t
27        } else if t < 0.5 {
28            q
29        } else if t < 2.0 / 3.0 {
30            p + (q - p) * (2.0 / 3.0 - t) * 6.0
31        } else {
32            p
33        }
34    };
35    [channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0)]
36}
37
38/// The stable per-solid display color, in sRGB (0..1 per channel).
39pub fn solid_color_srgb(name: &str) -> [f64; 3] {
40    let hue = ((fnv1a_utf16(name) >> 8) % 360) as f64 / 360.0;
41    hsl_to_rgb(hue, 0.45, 0.6)
42}
43
44/// sRGB electro-optical transfer (decode): sRGB component → linear.
45pub fn srgb_to_linear(c: f64) -> f64 {
46    if c <= 0.04045 {
47        c / 12.92
48    } else {
49        ((c + 0.055) / 1.055).powf(2.4)
50    }
51}
52
53/// A 0xRRGGBB hex color → linear-space RGB (for light/material constants).
54pub fn hex_to_linear(hex: u32) -> [f32; 3] {
55    let r = ((hex >> 16) & 0xff) as f64 / 255.0;
56    let g = ((hex >> 8) & 0xff) as f64 / 255.0;
57    let b = (hex & 0xff) as f64 / 255.0;
58    [
59        srgb_to_linear(r) as f32,
60        srgb_to_linear(g) as f32,
61        srgb_to_linear(b) as f32,
62    ]
63}
64
65/// A 0xRRGGBB hex color → sRGB-space RGB in 0..1 (for clear colors written to
66/// a non-sRGB target, where the bytes should equal the hex exactly).
67pub fn hex_to_srgb(hex: u32) -> [f64; 3] {
68    [
69        ((hex >> 16) & 0xff) as f64 / 255.0,
70        ((hex >> 8) & 0xff) as f64 / 255.0,
71        (hex & 0xff) as f64 / 255.0,
72    ]
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn solid_color_matches_js_reference() {
81        // Reference values computed with the original solidColor implementation.
82        // "E17": hash=0x9e08ad25, hue = (0x9e08ad % 360)/360.
83        let hash = fnv1a_utf16("E17");
84        assert_eq!(hash, {
85            let mut h: u32 = 2166136261;
86            for c in "E17".chars() {
87                h ^= c as u32;
88                h = h.wrapping_mul(16777619);
89            }
90            h
91        });
92        let rgb = solid_color_srgb("E17");
93        for c in rgb {
94            assert!((0.0..=1.0).contains(&c));
95        }
96        // Stable across calls (determinism).
97        assert_eq!(solid_color_srgb("E17"), solid_color_srgb("E17"));
98        assert_ne!(solid_color_srgb("E17"), solid_color_srgb("P.CU1"));
99    }
100
101    #[test]
102    fn hsl_formula_reference_points() {
103        // HSL(0, 0.45, 0.6) => q=0.78, p=0.42; r=channel(1/3)=0.78? No: hue 0
104        // puts red at channel(1/3)... verify grey when s=0.
105        let grey = hsl_to_rgb(0.37, 0.0, 0.6);
106        for c in grey {
107            assert!((c - 0.6).abs() < 1e-12);
108        }
109    }
110}