Skip to main content

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