Skip to main content

ph_color/
interp.rs

1//! Interpolation LUTs and color gradients.
2//!
3//! `N` for [`InterpLut`] must be `2^k + 1` in `{17, 33, 65, 257}` so the knot
4//! table can live in flash as a `const`.
5
6use core::marker::PhantomData;
7
8use crate::arith::lerp_q0_16;
9use crate::color::Color;
10use crate::encoding::{Encoded, Linear};
11use crate::fixed::Q0_16;
12use crate::space::{ColorSpace, Perceptual};
13
14const fn valid_lut_len(n: usize) -> bool {
15    n == 17 || n == 33 || n == 65 || n == 257
16}
17
18/// Piecewise-linear [`Q0_16`] → [`Q0_16`] table with `N` knots.
19pub struct InterpLut<const N: usize> {
20    knots: [Q0_16; N],
21    max_err_lsb: u16,
22}
23
24impl<const N: usize> Copy for InterpLut<N> {}
25
26impl<const N: usize> Clone for InterpLut<N> {
27    fn clone(&self) -> Self {
28        *self
29    }
30}
31
32impl<const N: usize> InterpLut<N> {
33    /// Construct from a flash-resident knot table.
34    ///
35    /// `max_err_lsb` is the bake-supplied bound (may be `0` until tables are
36    /// generated). `N` must be 17, 33, 65, or 257.
37    #[must_use]
38    pub const fn from_knots(knots: [Q0_16; N], max_err_lsb: u16) -> Self {
39        const { assert!(valid_lut_len(N), "InterpLut N must be 17, 33, 65, or 257") };
40        Self { knots, max_err_lsb }
41    }
42
43    /// Bake-supplied maximum absolute error in UQ0.16 LSBs.
44    #[must_use]
45    pub const fn max_err_lsb(&self) -> u16 {
46        self.max_err_lsb
47    }
48
49    /// Interpolate `x` between knots. Endpoints return the tabulated values.
50    ///
51    /// `N` is `17..=257` (enforced by [`Self::from_knots`]), so `segs` and
52    /// `pos` stay far inside [`u32`] — no 64-bit multiply, and
53    /// the `arith` reciprocal helper means no divide at all.
54    ///
55    /// There is no `x == 65535` early return. Clamping the segment index to
56    /// `N - 2` makes the top knot fall out of the general path: at
57    /// `x == 65535` the clamped remainder is exactly `65535`, so the blend
58    /// returns `knots[N - 1]` unchanged. One clamp replaces a branch, a
59    /// second table read, and a separate remainder division.
60    #[must_use]
61    #[allow(clippy::indexing_slicing)] // idx is proven `<= N-2`; idx+1 is `<= N-1`.
62    pub const fn lookup(&self, x: Q0_16) -> Q0_16 {
63        let last = N.saturating_sub(1);
64        let segs = last as u32;
65        let pos = (x.to_raw() as u32).saturating_mul(segs);
66        let hi = last.saturating_sub(1) as u32;
67        let raw = crate::arith::div_65535(pos);
68        let idx_u = if raw < hi { raw } else { hi };
69        let rem = crate::arith::rem_65535(pos, idx_u);
70        let idx = idx_u as usize;
71        lerp_q0_16(
72            self.knots[idx],
73            self.knots[idx.saturating_add(1)],
74            Q0_16::from_raw(rem),
75        )
76    }
77
78    /// Per-channel encode: `Linear<S>` → `Encoded<S>`.
79    #[must_use]
80    pub const fn encode<S: ColorSpace>(&self, color: Color<S, Linear>) -> Color<S, Encoded> {
81        let [c0, c1, c2] = color.ch;
82        Color::new([self.lookup(c0), self.lookup(c1), self.lookup(c2)])
83    }
84
85    /// Per-channel decode: `Encoded<S>` → `Linear<S>`.
86    #[must_use]
87    pub const fn decode<S: ColorSpace>(&self, color: Color<S, Encoded>) -> Color<S, Linear> {
88        let [c0, c1, c2] = color.ch;
89        Color::new([self.lookup(c0), self.lookup(c1), self.lookup(c2)])
90    }
91
92    /// Piecewise-linear lookup with unit `f32` `x`, clamped to `0.0..=1.0`.
93    /// Knots are UQ0.16 values converted to unit `f32`.
94    ///
95    /// Additive: does not change [`Self::lookup`].
96    #[cfg(feature = "f32")]
97    #[must_use]
98    pub fn lookup_f32(&self, x: f32) -> f32 {
99        let x = crate::color_f32::sat_unit(x);
100        let last = N.saturating_sub(1);
101        let Some(&last_knot) = self.knots.get(last) else {
102            return 0.0;
103        };
104        if last == 0 || x >= 1.0 {
105            return last_knot.to_f32();
106        }
107        if x <= 0.0 {
108            return match self.knots.first() {
109                Some(&k) => k.to_f32(),
110                None => 0.0,
111            };
112        }
113        let segs = last as f32;
114        let pos = x * segs;
115        let mut idx = pos as usize;
116        if idx >= last {
117            idx = last.saturating_sub(1);
118        }
119        let Some(&a_u) = self.knots.get(idx) else {
120            return last_knot.to_f32();
121        };
122        let b_u = match self.knots.get(idx.saturating_add(1)) {
123            Some(&k) => k,
124            None => a_u,
125        };
126        let frac = crate::color_f32::sat_unit(pos - (idx as f32));
127        crate::color_f32::lerp_unit(a_u.to_f32(), b_u.to_f32(), frac)
128    }
129
130    /// Per-channel `f32` encode: `Linear<S>` → `Encoded<S>`.
131    #[cfg(feature = "f32")]
132    #[must_use]
133    pub fn encode_f32<S: ColorSpace>(
134        &self,
135        color: crate::ColorF32<S, Linear>,
136    ) -> crate::ColorF32<S, Encoded> {
137        let [c0, c1, c2] = color.ch;
138        crate::ColorF32::new([
139            self.lookup_f32(c0),
140            self.lookup_f32(c1),
141            self.lookup_f32(c2),
142        ])
143    }
144
145    /// Per-channel `f32` decode: `Encoded<S>` → `Linear<S>`.
146    #[cfg(feature = "f32")]
147    #[must_use]
148    pub fn decode_f32<S: ColorSpace>(
149        &self,
150        color: crate::ColorF32<S, Encoded>,
151    ) -> crate::ColorF32<S, Linear> {
152        let [c0, c1, c2] = color.ch;
153        crate::ColorF32::new([
154            self.lookup_f32(c0),
155            self.lookup_f32(c1),
156            self.lookup_f32(c2),
157        ])
158    }
159}
160
161/// Piecewise-linear color stops. Sampled with a UQ0.16 parameter.
162pub struct Gradient<S: ColorSpace, E: crate::encoding::Encoding, const N: usize> {
163    stops: [Color<S, E>; N],
164    _pd: PhantomData<fn() -> (S, E)>,
165}
166
167impl<S: ColorSpace, E: crate::encoding::Encoding, const N: usize> Copy for Gradient<S, E, N> {}
168
169impl<S: ColorSpace, E: crate::encoding::Encoding, const N: usize> Clone for Gradient<S, E, N> {
170    fn clone(&self) -> Self {
171        *self
172    }
173}
174
175impl<S: ColorSpace, const N: usize> Gradient<S, Linear, N> {
176    /// Construct from `N` linear color stops (`N >= 2`).
177    #[must_use]
178    pub const fn from_stops(stops: [Color<S, Linear>; N]) -> Self {
179        const { assert!(N >= 2, "Gradient requires at least two stops") };
180        Self {
181            stops,
182            _pd: PhantomData,
183        }
184    }
185
186    /// Sample the gradient. `0` is the first stop, `65535` is the last.
187    #[must_use]
188    pub const fn sample(&self, t: Q0_16) -> Color<S, Linear> {
189        sample_stops(&self.stops, t)
190    }
191}
192
193impl<S: ColorSpace + Perceptual, const N: usize> Gradient<S, Encoded, N> {
194    /// Construct from encoded perceptual color stops (`N >= 2`).
195    #[must_use]
196    pub const fn from_stops(stops: [Color<S, Encoded>; N]) -> Self {
197        const { assert!(N >= 2, "Gradient requires at least two stops") };
198        Self {
199            stops,
200            _pd: PhantomData,
201        }
202    }
203
204    /// Sample the encoded perceptual gradient.
205    #[must_use]
206    pub const fn sample(&self, t: Q0_16) -> Color<S, Encoded> {
207        sample_stops(&self.stops, t)
208    }
209}
210
211/// `t` is [`Q0_16`] and `segs` is saturated into [`u32`] below, so `pos`
212/// stays in [`u32`] — no 64-bit multiply, and no divide — for every `N` a
213/// `const` stops array can plausibly hold. `N` has no compile-time upper
214/// bound (only `N >= 2`), so the cast from `usize` saturates instead of
215/// wrapping: a stops array past `u32::MAX + 1` entries is already outside
216/// what a `no_alloc` target could hold, but this keeps the position math
217/// gracefully wrong rather than silently wrapped for that unreachable case.
218#[allow(clippy::indexing_slicing)] // idx is proven `<= N-2`; idx+1 is `<= N-1`.
219const fn sample_stops<S: ColorSpace, E: crate::encoding::Encoding, const N: usize>(
220    stops: &[Color<S, E>; N],
221    t: Q0_16,
222) -> Color<S, E> {
223    let last = N.saturating_sub(1);
224    let segs = if last > u32::MAX as usize {
225        u32::MAX
226    } else {
227        last as u32
228    };
229    // `div_65535` is exact up to `65535 * 65535`; clamping `pos` keeps the
230    // unreachable `N > 65536` case gracefully wrong rather than silently
231    // outside the proven range, exactly as `saturating_mul` does above.
232    let pos = match (t.to_raw() as u32).saturating_mul(segs) {
233        p if p > 65535 * 65535 => 65535 * 65535,
234        p => p,
235    };
236    let hi = last.saturating_sub(1) as u32;
237    let raw = crate::arith::div_65535(pos);
238    let idx_u = if raw < hi { raw } else { hi };
239    let rem = crate::arith::rem_65535(pos, idx_u);
240    let idx = idx_u as usize;
241    lerp_color(
242        stops[idx],
243        stops[idx.saturating_add(1)],
244        Q0_16::from_raw(rem),
245    )
246}
247
248const fn lerp_color<S: ColorSpace, E: crate::encoding::Encoding>(
249    a: Color<S, E>,
250    b: Color<S, E>,
251    t: Q0_16,
252) -> Color<S, E> {
253    let [a0, a1, a2] = a.ch;
254    let [b0, b1, b2] = b.ch;
255    Color::new([
256        lerp_q0_16(a0, b0, t),
257        lerp_q0_16(a1, b1, t),
258        lerp_q0_16(a2, b2, t),
259    ])
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::space::Srgb;
266
267    fn ramp17() -> InterpLut<17> {
268        let mut knots = [0u16; 17];
269        for (i, slot) in knots.iter_mut().enumerate() {
270            let n = i as u32;
271            *slot = match n.saturating_mul(65535).checked_div(16) {
272                Some(v) if v <= u32::from(u16::MAX) => v as u16,
273                _ => 0,
274            };
275        }
276        InterpLut::from_knots(Q0_16::array_from_raw(knots), 0)
277    }
278
279    #[test]
280    fn endpoints_match_knots() {
281        let lut = ramp17();
282        assert_eq!(lut.lookup(Q0_16::ZERO), Q0_16::ZERO);
283        assert_eq!(lut.lookup(Q0_16::ONE), Q0_16::ONE);
284    }
285
286    #[test]
287    fn midpoint_between_first_knots() {
288        let lut = InterpLut::<17>::from_knots(
289            {
290                let mut k = [0u16; 17];
291                if let Some(slot) = k.get_mut(1) {
292                    *slot = 1000;
293                }
294                Q0_16::array_from_raw(k)
295            },
296            0,
297        );
298        let rem = match (2048u64)
299            .saturating_mul(16)
300            .checked_rem(u64::from(u16::MAX))
301        {
302            Some(r) if r <= u64::from(u16::MAX) => r as u16,
303            _ => 0,
304        };
305        let y = lut.lookup(Q0_16::from_raw(2048));
306        assert_eq!(
307            y,
308            lerp_q0_16(Q0_16::ZERO, Q0_16::from_raw(1000), Q0_16::from_raw(rem))
309        );
310    }
311
312    #[test]
313    fn increasing_table_is_monotonic() {
314        let lut = ramp17();
315        let mut prev = lut.lookup(Q0_16::ZERO);
316        for x in 1..=u16::MAX {
317            let y = lut.lookup(Q0_16::from_raw(x));
318            assert!(y >= prev, "x={x} y={y:?} prev={prev:?}");
319            prev = y;
320        }
321    }
322
323    #[test]
324    fn decreasing_table_is_monotonic() {
325        let mut knots = [0u16; 17];
326        for (i, slot) in knots.iter_mut().enumerate() {
327            let n = 16u32.saturating_sub(i as u32);
328            *slot = match n.saturating_mul(65535).checked_div(16) {
329                Some(v) if v <= u32::from(u16::MAX) => v as u16,
330                _ => 0,
331            };
332        }
333        let lut = InterpLut::from_knots(Q0_16::array_from_raw(knots), 0);
334        let mut prev = lut.lookup(Q0_16::ZERO);
335        for x in 1..=u16::MAX {
336            let y = lut.lookup(Q0_16::from_raw(x));
337            assert!(y <= prev, "x={x} y={y:?} prev={prev:?}");
338            prev = y;
339        }
340    }
341
342    #[test]
343    fn lerp_endpoints() {
344        let a = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([0, 0, 0]));
345        let b = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([100, 200, 300]));
346        assert_eq!(a.lerp(b, Q0_16::ZERO), a);
347        assert_eq!(a.lerp(b, Q0_16::ONE), b);
348    }
349
350    #[test]
351    fn gradient_endpoints() {
352        let g = Gradient::<Srgb, Linear, 2>::from_stops([
353            Color::new(Q0_16::array_from_raw([0, 0, 0])),
354            Color::new(Q0_16::array_from_raw([10, 20, 30])),
355        ]);
356        assert_eq!(g.sample(Q0_16::ZERO).ch, Q0_16::array_from_raw([0, 0, 0]));
357        assert_eq!(g.sample(Q0_16::ONE).ch, Q0_16::array_from_raw([10, 20, 30]));
358    }
359}