style/color/gamut/raytrace.rs
1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5//! Gamut mapping - Raytrace algorithm.
6//! <https://drafts.csswg.org/css-color-4/#gamut-mapping>
7
8use crate::color::{gamut::MIN_PRECISION, AbsoluteColor, ColorComponents, ColorSpace};
9
10impl AbsoluteColor {
11 /// 13.2.5. The Ray Trace Gamut Mapping
12 /// <https://drafts.csswg.org/css-color-4/#GMA-Raytrace>
13 pub fn gamut_map_raytrace(&self, dest_color_space: ColorSpace) -> Self {
14 macro_rules! in_range {
15 ($l:expr, $c:expr, $h:expr) => {{
16 $c >= $l && $c <= $h
17 }};
18 }
19
20 const MIN_L: f32 = MIN_PRECISION;
21 const MAX_L: f32 = 1.0;
22
23 // Get the linear version of the destination color space;
24 // else, use the same space for the destination.
25 // NOTE that this will cause temporary approximations for wide-gamut
26 // RGB spaces beyond DisplayP3 (A98RGB, ProPhoto, and Rec2020) that
27 // currently don't have linear versions built-out for them.
28 // See comments/notes on [`ColorSpace::get_linear_color_space()`].
29 let dest_linear_color_space = dest_color_space
30 .get_linear_color_space()
31 .unwrap_or(dest_color_space);
32
33 // 1. if destination has no gamut limits (XYZ-D65, XYZ-D50, Lab, LCH,
34 // Oklab, OkLCh) convert origin to destination and return it as the
35 // gamut mapped color
36 if matches!(
37 dest_color_space,
38 ColorSpace::Lab
39 | ColorSpace::Lch
40 | ColorSpace::Oklab
41 | ColorSpace::Oklch
42 | ColorSpace::XyzD50
43 | ColorSpace::XyzD65
44 ) {
45 return self.to_color_space(dest_color_space);
46 }
47
48 // 2. let `origin_OkLCh` be `origin` converted from `origin color
49 // space` to the OkLCh color space
50 let origin_oklch = self.to_color_space(ColorSpace::Oklch);
51
52 // 3. if the Lightness of `origin_OkLCh` is greater than or equal to
53 // 100%, convert `oklab(1 0 0 / origin.alpha)` to `destination` and
54 // return it as the gamut mapped color
55 if origin_oklch.components.0 >= MAX_L {
56 return AbsoluteColor::new(ColorSpace::Oklab, 1.0, 0.0, 0.0, self.alpha)
57 .to_color_space(dest_color_space);
58 }
59
60 // 4. if the Lightness of `origin_OkLCh` is less than than or equal to
61 // 0%, convert `oklab(0 0 0 / origin.alpha)` to `destination` and
62 // return it as the gamut mapped color
63 if origin_oklch.components.0 <= MIN_L {
64 return AbsoluteColor::new(ColorSpace::Oklab, 0.0, 0.0, 0.0, self.alpha)
65 .to_color_space(dest_color_space);
66 }
67
68 // 5. let `l_origin` be the OkLCh lightness component of `origin_OkLCh`
69 // 6. let `h_origin` be the OkLCh hue component of `origin_OkLCh`
70 let ColorComponents(l_origin, _, h_origin) = origin_oklch.components;
71
72 // 7. let `anchor` be an achromatic OkLCh color formed with `l_origin`
73 // as lightness, 0 as chroma, and `h_origin` as hue, converted to
74 // the linear-light form of destination
75 let mut anchor = AbsoluteColor::new(ColorSpace::Oklch, l_origin, 0.0, h_origin, self.alpha)
76 .to_color_space(dest_linear_color_space);
77
78 // 8. let `origin_rgb` be `origin_OkLCh` converted to the linear-light
79 // form of destination
80 let mut origin_rgb = origin_oklch.to_color_space(dest_linear_color_space);
81
82 // 9. if origin_rgb is not in gamut
83 if !origin_rgb.in_gamut() {
84 // 9.1. let `low` be 1E-6 [^1]
85 const LOW: f32 = 1.0e-6;
86
87 // 9.2 let `high` be 1.0 - `low` [^2]
88 const HIGH: f32 = 1.0 - LOW;
89
90 // 9.3 let `last` be `origin_rgb`
91 let mut last = origin_rgb;
92
93 // We're doing 4 cycles of ray tracing.
94 // 9.4 `for (i=0; i<4; i++)`:
95 for i in 0..4 {
96 // 9.4.1. if (`i > 0`)
97 if i > 0 {
98 // 9.4.1.1. let `current_OkLCh` be `origin_rgb` converted to OkLCh
99 let mut current_oklch = origin_rgb.to_color_space(ColorSpace::Oklch);
100
101 // 9.4.1.2. let the lightness of `current_OkLCh` be `l_origin`
102 current_oklch.components.0 = l_origin;
103 // 9.4.1.3. let the hue of `current_OkLCh` be `h_origin` [^3]
104 current_oklch.components.2 = h_origin;
105
106 // 9.4.1.4. let `origin_rgb` be `current_OkLCh` converted to the
107 // linear-light form of destination
108 origin_rgb = current_oklch.to_color_space(dest_linear_color_space);
109 }
110
111 // 9.4.2. **Cast a ray** from `anchor` to `origin_rgb` and let
112 // `intersection` be the intersection of this ray with the
113 // gamut boundary
114 let intersection = Self::cast_ray(&anchor.components, &origin_rgb.components);
115
116 // 9.4.3. if an intersection was not found, let `origin_rgb` be
117 // `last` and exit the loop [^5]
118 let Some(intersection) = intersection else {
119 origin_rgb = last;
120 break;
121 };
122
123 // 9.4.4. if (`i > 0`) AND (each component of `origin_rgb` is
124 // between `low` and `high`) then let `anchor` be
125 // `origin_rgb` [^4]
126 if (i > 0)
127 && in_range!(LOW, origin_rgb.components.0, HIGH)
128 && in_range!(LOW, origin_rgb.components.1, HIGH)
129 && in_range!(LOW, origin_rgb.components.2, HIGH)
130 {
131 anchor = origin_rgb;
132 }
133
134 // 9.4.5. let `origin_rgb` be `intersection`
135 origin_rgb.components = intersection;
136
137 // 9.4.6. let `last` be `intersection`
138 last.components = intersection;
139 }
140 }
141
142 // 10. let clip(color) be a function which converts color to
143 // destination, clamps each component to the bounds of the
144 // reference range for that component, and returns the result
145 // See [`Self::clip`] and [`Self::clip_to_dest_space`].
146
147 // NOTE: in the 2026-02-27 draft, this line doesn't make sense.
148 // I've posted a question/issue to the CSS Color 4 team about it.
149 // See <https://github.com/w3c/csswg-drafts/issues/10579#issuecomment-4122677476>
150 // Isaac (@facelessuser) replied that it is ambiguous, but ATOW this hasn't
151 // been fixed in the working copy pseudo-code.
152 // See <https://github.com/w3c/csswg-drafts/issues/10579#issuecomment-4123490623>
153 // 11. set clipped to clip(current)
154 //
155 // In the meantime, this was the logic from the 2026-02-02 draft that worked:
156 // 13. let `clipped` be `origin_rgb` clipped to gamut (components in
157 // the range 0 to 1), thus trimming off any noise due to floating
158 // point inaccuracy
159 let clipped = origin_rgb.clip_to_dest_space(dest_color_space);
160
161 // 12. return `clipped` as the gamut mapped color
162 clipped
163 }
164
165 /// To **cast a ray** through a linear-light RGB space from `start` to
166 /// `end` (in gamut mapping, `start` is an anchor within the RGB gamut and
167 /// `end` is the gamut mapped color, on the cubical gamut surface)
168 /// <https://drafts.csswg.org/css-color-4/#GMA-Raytrace>
169 fn cast_ray(start: &ColorComponents, end: &ColorComponents) -> Option<ColorComponents> {
170 const MAGIC_EPSILON: f32 = 1.0e-12;
171
172 // 1. let `bmin` and `bmax` be 3-element arrays with the gamut’s lower
173 // and upper bounds, respectively [^6]
174 // NOTE: assuming these are always tied to RGB bounds [0.0, 1.0]
175 let bmin = [0.0, 0.0, 0.0];
176 let bmax = [1.0, 1.0, 1.0];
177
178 // 2. let `tfar` be `infinity` (or some very large number)
179 let mut tfar = std::f32::INFINITY;
180
181 // 3. let `tnear` be `-infinity` (or some very large, negative number)
182 let mut tnear = std::f32::NEG_INFINITY;
183
184 // 4. let `direction` be a 3-element array
185 let mut direction = [0.0, 0.0, 0.0];
186
187 // reshape start and end so we can iterate over them
188 let start_array = start.to_array();
189 let end_array = end.to_array();
190
191 // 5. `for (i = 0; i < 3; i++)`:
192 for i in 0..3 {
193 // 5.1. let `a` be `start[i]`
194 let a = start_array[i];
195
196 // 5.2. let `b` be `end[i]`
197 let b = end_array[i];
198
199 // 5.3. let `d` be `b - a`
200 let d = b - a;
201
202 // 5.4. let `direction[i]` be `d`
203 direction[i] = d;
204
205 // 5.5. if abs(d) > MAGIC_EPSILON:
206 // NOTE the 2026-02-27 spec is incorrect; it uses less-than -- should be greater-than.
207 // Reference impls colorjs.io and ColorAide both use greater-than.
208 // Issue reported to CSS Color 4 team; yet to be fixed in working draft ATOW.
209 // See <https://github.com/w3c/csswg-drafts/issues/10579#issuecomment-4122988782>
210 if d.abs() > MAGIC_EPSILON {
211 // 5.5.1. let `inv_d` be `1 / d`
212 let inv_d = 1.0 / d;
213
214 // 5.5.2. let `t1` be `(bmin[i] - a) * inv_d`
215 let t1 = (bmin[i] - a) * inv_d;
216
217 // 5.5.3. let `t2` be `(bmax[i] - a) * inv_d`
218 let t2 = (bmax[i] - a) * inv_d;
219
220 // 5.5.4. let `tnear` be `max(min(t1, t2), tnear)`
221 tnear = t1.min(t2).max(tnear);
222
223 // 5.5.5. let `tfar` be `min(max(t1, t2), tfar)`
224 tfar = t1.max(t2).min(tfar);
225 }
226 // 5.6. else if (`a < bmin[i]` or `a > bmax[i]`)
227 // * return `INTERSECTION NOT FOUND`
228 else if a < bmin[i] || a > bmax[i] {
229 return None;
230 }
231 }
232
233 // 6. if (`tnear > tfar` or `tfar < 0`)
234 // * return `INTERSECTION NOT FOUND`
235 if (tnear > tfar) || (tfar < 0.0) {
236 return None;
237 }
238
239 // 7. if `tnear < 0`
240 // * let `tnear` be `tfar` [^7]
241 if tnear < 0.0 {
242 tnear = tfar;
243 }
244
245 // 8. if tnear is infinite (or matches the initial very large value)
246 // * return `INTERSECTION NOT FOUND`
247 if tnear.is_infinite() {
248 return None;
249 }
250
251 let mut result = [0.0, 0.0, 0.0];
252 // 9. for (`i = 0; i < 3; i++`):
253 // * let `result[i]` be `start[i] + direction[i] * tnear`
254 for i in 0..3 {
255 result[i] = start_array[i] + direction[i] * tnear;
256 }
257
258 // 10. return `result`
259 Some(ColorComponents(result[0], result[1], result[2]))
260 }
261}