Skip to main content

ph_color/
color_f32.rs

1//! Additive `f32` color triple (`feature = "f32"`).
2//!
3//! Channels are `0.0..=1.0`, not UQ0.16 integers. This module does not wrap or
4//! replace the fixed-point numeric core.
5
6use core::marker::PhantomData;
7
8use crate::color::Color;
9use crate::encoding::{Encoded, Encoding, Linear};
10use crate::fixed::{Q0_16, Q4_28};
11use crate::space::{ColorSpace, Perceptual};
12
13/// Maximum absolute error of the `f32` path versus frozen UQ0.16 W7 goldens,
14/// in LSBs after rounding `(channel * 65535)` to nearest (half away from zero).
15///
16/// Host measurement on matrix, gain, LUT lookup, and lerp CSVs: max 1 LSB
17/// (gain: 0). The asserted bound is 4 LSB. The fixed-point path remains
18/// bit-identical when this feature is enabled.
19///
20/// Oklab has no `f32` entry points; that remains out of scope.
21pub const F32_MAX_ERR_LSB: u16 = 4;
22
23const U16_MAX_F: f32 = 65535.0;
24
25/// Three `f32` channels tagged with space and encoding.
26///
27/// Values are linear light or encoded in `0.0..=1.0`. Enabling
28/// `feature = "f32"` does not change any fixed-point result.
29///
30/// Oklab `f32` conversion is out of scope.
31pub struct ColorF32<S: ColorSpace, E: Encoding> {
32    /// Channel values in `0.0..=1.0` (`0.0` = 0, `1.0` = UQ0.16 full scale).
33    pub ch: [f32; 3],
34    _pd: PhantomData<fn() -> (S, E)>,
35}
36
37impl<S: ColorSpace, E: Encoding> Copy for ColorF32<S, E> {}
38
39impl<S: ColorSpace, E: Encoding> Clone for ColorF32<S, E> {
40    fn clone(&self) -> Self {
41        *self
42    }
43}
44
45impl<S: ColorSpace, E: Encoding> PartialEq for ColorF32<S, E> {
46    fn eq(&self, other: &Self) -> bool {
47        let [a0, a1, a2] = self.ch;
48        let [b0, b1, b2] = other.ch;
49        a0.to_bits() == b0.to_bits() && a1.to_bits() == b1.to_bits() && a2.to_bits() == b2.to_bits()
50    }
51}
52
53impl<S: ColorSpace, E: Encoding> Eq for ColorF32<S, E> {}
54
55impl<S: ColorSpace, E: Encoding> core::fmt::Debug for ColorF32<S, E> {
56    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
57        f.debug_struct("ColorF32").field("ch", &self.ch).finish()
58    }
59}
60
61impl<S: ColorSpace, E: Encoding> ColorF32<S, E> {
62    /// Construct from three `0.0..=1.0` channels.
63    #[must_use]
64    pub const fn new(ch: [f32; 3]) -> Self {
65        Self {
66            ch,
67            _pd: PhantomData,
68        }
69    }
70
71    /// Convert to [`Q0_16`] [`Color`] by rounding to nearest and saturating.
72    #[must_use]
73    pub fn to_color(self) -> Color<S, E> {
74        let [c0, c1, c2] = self.ch;
75        Color::new([unit_to_q0_16(c0), unit_to_q0_16(c1), unit_to_q0_16(c2)])
76    }
77}
78
79impl<S: ColorSpace> ColorF32<S, Linear> {
80    /// Linear interpolation. `t` is `0.0..=1.0` (`0.0` returns `self`).
81    #[must_use]
82    pub fn lerp(self, other: Self, t: f32) -> Self {
83        let [a0, a1, a2] = self.ch;
84        let [b0, b1, b2] = other.ch;
85        Self::new([
86            lerp_unit(a0, b0, t),
87            lerp_unit(a1, b1, t),
88            lerp_unit(a2, b2, t),
89        ])
90    }
91}
92
93impl<S: ColorSpace + Perceptual> ColorF32<S, Encoded> {
94    /// Interpolate encoded values only when the space is [`Perceptual`].
95    #[must_use]
96    pub fn lerp(self, other: Self, t: f32) -> Self {
97        let [a0, a1, a2] = self.ch;
98        let [b0, b1, b2] = other.ch;
99        Self::new([
100            lerp_unit(a0, b0, t),
101            lerp_unit(a1, b1, t),
102            lerp_unit(a2, b2, t),
103        ])
104    }
105}
106
107impl<S: ColorSpace, E: Encoding> From<Color<S, E>> for ColorF32<S, E> {
108    fn from(color: Color<S, E>) -> Self {
109        color.to_f32()
110    }
111}
112
113impl<S: ColorSpace, E: Encoding> From<ColorF32<S, E>> for Color<S, E> {
114    fn from(color: ColorF32<S, E>) -> Self {
115        color.to_color()
116    }
117}
118
119/// Saturate to the unit interval. NaN becomes `0.0`; ±infinity clamp to the
120/// nearer endpoint (`-inf` → `0.0`, `+inf` → `1.0`).
121///
122/// `max`/`min` rather than a comparison chain: IEEE 754 `maxNum`/`minNum`
123/// return the non-NaN operand, which is exactly the NaN rule above, and a
124/// target with an FPU lowers the pair to two instructions with no branch.
125///
126/// The trailing `+ 0.0` is load-bearing. `f32::max` may return **either**
127/// operand when both are zeros of different sign, so `max(-0.0, 0.0)` is
128/// `-0.0` on some targets and `+0.0` on others; the comparison chain this
129/// replaced always produced `+0.0`. Adding `+0.0` normalises that one case
130/// (`-0.0 + 0.0 == +0.0` under round-to-nearest) and is the exact identity
131/// for every other finite input, so the result stays bit-stable across
132/// targets. `sat_unit_matches_the_comparison_chain_bit_for_bit` pins it.
133#[must_use]
134#[allow(clippy::manual_clamp)] // `clamp` returns NaN for NaN; this must return 0.0.
135pub(crate) fn sat_unit(x: f32) -> f32 {
136    x.max(0.0).min(1.0) + 0.0
137}
138
139/// Unit `f32` to [`Q0_16`]: round to nearest (half away from zero), saturate.
140///
141/// Implemented without `libm` (`f32::round` is not in `core` on this MSRV).
142#[must_use]
143pub(crate) fn unit_to_q0_16(x: f32) -> Q0_16 {
144    if x.is_nan() || x <= 0.0 {
145        return Q0_16::ZERO;
146    }
147    if x >= 1.0 {
148        return Q0_16::ONE;
149    }
150    let scaled = x * U16_MAX_F;
151    if scaled >= U16_MAX_F {
152        return Q0_16::ONE;
153    }
154    // `scaled` is in `(0, 65535)`. `as u32` truncates toward zero.
155    let trunc = scaled as u32;
156    let frac = scaled - (trunc as f32);
157    let rounded = if frac >= 0.5 {
158        trunc.saturating_add(1)
159    } else {
160        trunc
161    };
162    if rounded >= u32::from(u16::MAX) {
163        Q0_16::ONE
164    } else {
165        // Proven bounded: `0..=65534`.
166        Q0_16::from_raw(rounded as u16)
167    }
168}
169
170/// Blend `a` toward `b` with unit `t`, saturating the result.
171#[must_use]
172pub(crate) fn lerp_unit(a: f32, b: f32, t: f32) -> f32 {
173    let a = sat_unit(a);
174    let b = sat_unit(b);
175    let t = sat_unit(t);
176    if t <= 0.0 {
177        return a;
178    }
179    if t >= 1.0 {
180        return b;
181    }
182    sat_unit(a + (b - a) * t)
183}
184
185/// One matrix row: decoded [`Q4_28`] × unit channels, saturate to `0.0..=1.0`.
186#[must_use]
187pub(crate) fn apply_row_f32(coefs: [Q4_28; 3], ch: [f32; 3]) -> f32 {
188    let [k0, k1, k2] = coefs;
189    let [c0, c1, c2] = ch;
190    let c0 = sat_unit(c0);
191    let c1 = sat_unit(c1);
192    let c2 = sat_unit(c2);
193    sat_unit(k0.to_f32() * c0 + k1.to_f32() * c1 + k2.to_f32() * c2)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::color::Color;
200    use crate::encoding::{Encoded, Linear};
201    use crate::space::Srgb;
202
203    #[test]
204    fn sat_unit_matches_the_comparison_chain_bit_for_bit() {
205        // The branchless form replaced `is_nan() || x <= 0.0 ...`; pin the
206        // cases where IEEE `maxNum`/`minNum` could plausibly differ.
207        fn reference(x: f32) -> f32 {
208            if x.is_nan() || x <= 0.0 {
209                0.0
210            } else if x >= 1.0 {
211                1.0
212            } else {
213                x
214            }
215        }
216        for x in [
217            f32::NAN,
218            -f32::NAN,
219            f32::INFINITY,
220            f32::NEG_INFINITY,
221            -0.0,
222            0.0,
223            f32::MIN_POSITIVE,
224            -f32::MIN_POSITIVE,
225            1.0,
226            1.0 - f32::EPSILON,
227            1.0 + f32::EPSILON,
228            0.5,
229            -1.0,
230            1e30,
231            -1e30,
232        ] {
233            assert_eq!(
234                sat_unit(x).to_bits(),
235                reference(x).to_bits(),
236                "x={x:?} bits={:08x}",
237                x.to_bits()
238            );
239        }
240    }
241
242    #[test]
243    fn positive_infinity_saturates_high() {
244        assert_eq!(sat_unit(f32::INFINITY), 1.0);
245        assert_eq!(sat_unit(f32::NEG_INFINITY), 0.0);
246        assert_eq!(sat_unit(f32::NAN), 0.0);
247        assert_eq!(unit_to_q0_16(f32::INFINITY), Q0_16::ONE);
248        assert_eq!(unit_to_q0_16(f32::NEG_INFINITY), Q0_16::ZERO);
249        assert_eq!(unit_to_q0_16(f32::NAN), Q0_16::ZERO);
250        let lut = crate::interp::InterpLut::<17>::from_knots(
251            Q0_16::array_from_raw([
252                0, 4096, 8192, 12288, 16384, 20480, 24576, 28672, 32768, 36864, 40960, 45056,
253                49152, 53248, 57344, 61440, 65535,
254            ]),
255            0,
256        );
257        assert_eq!(lut.lookup_f32(f32::INFINITY), 1.0);
258        assert_eq!(lut.lookup_f32(f32::NEG_INFINITY), 0.0);
259        let one = Q4_28::ONE;
260        let zero = Q4_28::ZERO;
261        let m = crate::matrix::Matrix3::<Srgb, Srgb>::from_q428([
262            [one, zero, zero],
263            [zero, one, zero],
264            [zero, zero, one],
265        ]);
266        let mixed = ColorF32::<Srgb, Linear>::new([0.5, f32::NAN, 0.5]);
267        assert_eq!(m.apply_f32(mixed).ch, [0.5, 0.0, 0.5]);
268    }
269
270    #[test]
271    fn endpoints_round_trip_to_u16() {
272        let z = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([0, 0, 0]));
273        let f = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([65535, 65535, 65535]));
274        assert_eq!(ColorF32::from(z).to_color(), z);
275        assert_eq!(ColorF32::from(f).to_color(), f);
276    }
277
278    #[test]
279    fn lerp_endpoints() {
280        let a = ColorF32::<Srgb, Linear>::new([0.0, 0.0, 0.0]);
281        let b = ColorF32::<Srgb, Linear>::new([1.0, 0.5, 0.0]);
282        assert_eq!(a.lerp(b, 0.0), a);
283        assert_eq!(a.lerp(b, 1.0), b);
284        let hi = ColorF32::<Srgb, Linear>::new([1.0, 1.0, 1.0]);
285        let lo = ColorF32::<Srgb, Linear>::new([1e-8, 1e-8, 1e-8]);
286        assert_eq!(hi.lerp(lo, 1.0), lo);
287        let nan = ColorF32::<Srgb, Linear>::new([f32::NAN, f32::NAN, f32::NAN]);
288        let one = ColorF32::<Srgb, Linear>::new([1.0, 1.0, 1.0]);
289        assert_eq!(nan.lerp(one, 0.5).ch, [0.5, 0.5, 0.5]);
290    }
291
292    fn assert_send_sync<T: Send + Sync>() {}
293
294    #[test]
295    fn color_f32_is_send_sync() {
296        assert_send_sync::<ColorF32<Srgb, Linear>>();
297        assert_send_sync::<ColorF32<Srgb, Encoded>>();
298    }
299}