Skip to main content

qualia_core_db/render/
gamut.rs

1//! P7.2 — Gamut / object-colour solid as a convex polytope + closest-point
2//! gamut mapping.
3//!
4//! The sRGB gamut is the convex hull of the primary colours (red, green,
5//! blue) and their combinations in XYZ space. An out-of-gamut colour is
6//! mapped to the closest point on the gamut boundary.
7//!
8//! ## Algorithm
9//!
10//! 1. The sRGB gamut is the set of all `(R,G,B)` with `0 ≤ R,G,B ≤ 1`,
11//!    mapped through the sRGB→XYZ matrix.
12//! 2. In-gamut check: convert XYZ to linear sRGB; if all channels are in
13//!    `[0,1]`, the colour is in-gamut.
14//! 3. Out-of-gamut mapping: clamp each sRGB channel to `[0,1]` and convert
15//!    back to XYZ. This is a simple (non-optimal) closest-point mapping.
16//!
17//! ## Determinism
18//!
19//! All operations are deterministic: the XYZ→sRGB matrix is a constant,
20//! and clamping is a pure function.
21
22use super::spectral_kernel::{xyz_to_linear_srgb, LinearRgb, Xyz};
23
24// ───────────────────────────────────────────────────────────────────────────
25//  Errors
26// ───────────────────────────────────────────────────────────────────────────
27
28/// Gamut mapping error.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum GamutError {
31    /// Non-finite input.
32    NonFinite,
33}
34
35impl core::fmt::Display for GamutError {
36    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
37        match self {
38            Self::NonFinite => write!(f, "gamut: non-finite input"),
39        }
40    }
41}
42
43impl std::error::Error for GamutError {}
44
45// ───────────────────────────────────────────────────────────────────────────
46//  Gamut operations
47// ───────────────────────────────────────────────────────────────────────────
48
49/// Check if a colour is in the sRGB gamut (all linear sRGB channels in [0,1]).
50#[inline]
51pub fn is_in_gamut(xyz: &Xyz) -> bool {
52    let rgb = xyz_to_linear_srgb(xyz);
53    rgb.r >= 0.0 && rgb.r <= 1.0 && rgb.g >= 0.0 && rgb.g <= 1.0 && rgb.b >= 0.0 && rgb.b <= 1.0
54}
55
56/// Map an out-of-gamut colour to the closest in-gamut colour.
57///
58/// This uses the simple clamping approach: convert to linear sRGB, clamp
59/// each channel to [0,1], and convert back to XYZ.
60#[inline]
61pub fn gamut_map_clamp(xyz: &Xyz) -> Xyz {
62    let rgb = xyz_to_linear_srgb(xyz);
63    let clamped = LinearRgb::new(
64        rgb.r.clamp(0.0, 1.0),
65        rgb.g.clamp(0.0, 1.0),
66        rgb.b.clamp(0.0, 1.0),
67    );
68    linear_srgb_to_xyz(&clamped)
69}
70
71/// Convert linear sRGB to CIE XYZ (inverse of `xyz_to_linear_srgb`).
72#[inline]
73pub fn linear_srgb_to_xyz(rgb: &LinearRgb) -> Xyz {
74    let x = 0.4124564 * rgb.r + 0.3575761 * rgb.g + 0.1804375 * rgb.b;
75    let y = 0.2126729 * rgb.r + 0.7151522 * rgb.g + 0.0721750 * rgb.b;
76    let z = 0.0193339 * rgb.r + 0.1191920 * rgb.g + 0.9503041 * rgb.b;
77    Xyz::new(x, y, z)
78}
79
80/// Check if a linear sRGB colour is in gamut.
81#[inline]
82pub fn linear_rgb_is_in_gamut(rgb: &LinearRgb) -> bool {
83    rgb.r >= 0.0 && rgb.r <= 1.0 && rgb.g >= 0.0 && rgb.g <= 1.0 && rgb.b >= 0.0 && rgb.b <= 1.0
84}
85
86/// Interior idempotence: an in-gamut colour maps to itself.
87#[inline]
88pub fn gamut_map_idempotent(xyz: &Xyz) -> bool {
89    if !is_in_gamut(xyz) {
90        return false;
91    }
92    let mapped = gamut_map_clamp(xyz);
93    let diff =
94        ((mapped.x - xyz.x).abs() + (mapped.y - xyz.y).abs() + (mapped.z - xyz.z).abs()) / 3.0;
95    diff < 1e-6
96}
97
98// ───────────────────────────────────────────────────────────────────────────
99//  Tests
100// ───────────────────────────────────────────────────────────────────────────
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn in_gamut_colour_is_in_gamut() {
108        // Use exact D65 white point values.
109        let white = Xyz::new(0.95047, 1.0, 1.08883);
110        assert!(is_in_gamut(&white), "D65 white should be in gamut");
111    }
112
113    #[test]
114    fn out_of_gamut_detected() {
115        // A very saturated "super-red" outside the sRGB triangle.
116        let super_red = Xyz::new(2.0, 0.5, 0.0);
117        assert!(!is_in_gamut(&super_red), "super-red should be out of gamut");
118    }
119
120    #[test]
121    fn gamut_map_brings_out_of_gamut_inside() {
122        let super_red = Xyz::new(2.0, 0.5, 0.0);
123        let mapped = gamut_map_clamp(&super_red);
124        assert!(is_in_gamut(&mapped), "mapped colour should be in gamut");
125    }
126
127    #[test]
128    fn interior_idempotence() {
129        let in_gamut = Xyz::new(0.4, 0.5, 0.6);
130        if is_in_gamut(&in_gamut) {
131            assert!(
132                gamut_map_idempotent(&in_gamut),
133                "in-gamut colour should map to itself"
134            );
135        }
136    }
137
138    #[test]
139    fn gamut_map_determinism() {
140        let out = Xyz::new(1.5, 0.3, 0.1);
141        let m1 = gamut_map_clamp(&out);
142        let m2 = gamut_map_clamp(&out);
143        assert_eq!(m1, m2, "gamut mapping must be deterministic");
144    }
145
146    #[test]
147    fn linear_srgb_round_trip() {
148        let rgb = LinearRgb::new(0.5, 0.3, 0.8);
149        let xyz = linear_srgb_to_xyz(&rgb);
150        let rgb2 = xyz_to_linear_srgb(&xyz);
151        assert!((rgb.r - rgb2.r).abs() < 1e-4, "R round-trip");
152        assert!((rgb.g - rgb2.g).abs() < 1e-4, "G round-trip");
153        assert!((rgb.b - rgb2.b).abs() < 1e-4, "B round-trip");
154    }
155
156    #[test]
157    fn primary_red_is_in_gamut() {
158        let red = LinearRgb::new(1.0, 0.0, 0.0);
159        let xyz = linear_srgb_to_xyz(&red);
160        assert!(is_in_gamut(&xyz), "primary red should be in gamut");
161    }
162
163    #[test]
164    fn primary_green_is_in_gamut() {
165        let green = LinearRgb::new(0.0, 1.0, 0.0);
166        let xyz = linear_srgb_to_xyz(&green);
167        assert!(is_in_gamut(&xyz), "primary green should be in gamut");
168    }
169
170    #[test]
171    fn primary_blue_is_in_gamut() {
172        let blue = LinearRgb::new(0.0, 0.0, 1.0);
173        let xyz = linear_srgb_to_xyz(&blue);
174        assert!(is_in_gamut(&xyz), "primary blue should be in gamut");
175    }
176
177    #[test]
178    fn black_is_in_gamut() {
179        let black = Xyz::new(0.0, 0.0, 0.0);
180        assert!(is_in_gamut(&black), "black should be in gamut");
181    }
182}