qualia_core_db/render/
gamut.rs1use super::spectral_kernel::{xyz_to_linear_srgb, LinearRgb, Xyz};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum GamutError {
31 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#[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#[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#[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#[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#[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#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn in_gamut_colour_is_in_gamut() {
108 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 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}