ph-color 0.1.1

Fixed-point no_std color math for embedded targets: conversion, transfer functions, matrices, gain, and interpolation
Documentation
//! Interpolation LUTs and color gradients.
//!
//! `N` for [`InterpLut`] must be `2^k + 1` in `{17, 33, 65, 257}` so the knot
//! table can live in flash as a `const`.

use core::marker::PhantomData;

use crate::arith::lerp_q0_16;
use crate::color::Color;
use crate::encoding::{Encoded, Linear};
use crate::fixed::Q0_16;
use crate::space::{ColorSpace, Perceptual};

const fn valid_lut_len(n: usize) -> bool {
    n == 17 || n == 33 || n == 65 || n == 257
}

/// Piecewise-linear [`Q0_16`] → [`Q0_16`] table with `N` knots.
pub struct InterpLut<const N: usize> {
    knots: [Q0_16; N],
    max_err_lsb: u16,
}

impl<const N: usize> Copy for InterpLut<N> {}

impl<const N: usize> Clone for InterpLut<N> {
    fn clone(&self) -> Self {
        *self
    }
}

impl<const N: usize> InterpLut<N> {
    /// Construct from a flash-resident knot table.
    ///
    /// `max_err_lsb` is the bake-supplied bound (may be `0` until tables are
    /// generated). `N` must be 17, 33, 65, or 257.
    #[must_use]
    pub const fn from_knots(knots: [Q0_16; N], max_err_lsb: u16) -> Self {
        const { assert!(valid_lut_len(N), "InterpLut N must be 17, 33, 65, or 257") };
        Self { knots, max_err_lsb }
    }

    /// Bake-supplied maximum absolute error in UQ0.16 LSBs.
    #[must_use]
    pub const fn max_err_lsb(&self) -> u16 {
        self.max_err_lsb
    }

    /// Interpolate `x` between knots. Endpoints return the tabulated values.
    ///
    /// `N` is `17..=257` (enforced by [`Self::from_knots`]), so `segs` and
    /// `pos` stay far inside [`u32`] — no 64-bit multiply, and
    /// the `arith` reciprocal helper means no divide at all.
    ///
    /// There is no `x == 65535` early return. Clamping the segment index to
    /// `N - 2` makes the top knot fall out of the general path: at
    /// `x == 65535` the clamped remainder is exactly `65535`, so the blend
    /// returns `knots[N - 1]` unchanged. One clamp replaces a branch, a
    /// second table read, and a separate remainder division.
    #[must_use]
    #[allow(clippy::indexing_slicing)] // idx is proven `<= N-2`; idx+1 is `<= N-1`.
    pub const fn lookup(&self, x: Q0_16) -> Q0_16 {
        let last = N.saturating_sub(1);
        let segs = last as u32;
        let pos = (x.to_raw() as u32).saturating_mul(segs);
        let hi = last.saturating_sub(1) as u32;
        let raw = crate::arith::div_65535(pos);
        let idx_u = if raw < hi { raw } else { hi };
        let rem = crate::arith::rem_65535(pos, idx_u);
        let idx = idx_u as usize;
        lerp_q0_16(
            self.knots[idx],
            self.knots[idx.saturating_add(1)],
            Q0_16::from_raw(rem),
        )
    }

    /// Per-channel encode: `Linear<S>` → `Encoded<S>`.
    #[must_use]
    pub const fn encode<S: ColorSpace>(&self, color: Color<S, Linear>) -> Color<S, Encoded> {
        let [c0, c1, c2] = color.ch;
        Color::new([self.lookup(c0), self.lookup(c1), self.lookup(c2)])
    }

    /// Per-channel decode: `Encoded<S>` → `Linear<S>`.
    #[must_use]
    pub const fn decode<S: ColorSpace>(&self, color: Color<S, Encoded>) -> Color<S, Linear> {
        let [c0, c1, c2] = color.ch;
        Color::new([self.lookup(c0), self.lookup(c1), self.lookup(c2)])
    }

    /// Piecewise-linear lookup with unit `f32` `x`, clamped to `0.0..=1.0`.
    /// Knots are UQ0.16 values converted to unit `f32`.
    ///
    /// Additive: does not change [`Self::lookup`].
    #[cfg(feature = "f32")]
    #[must_use]
    pub fn lookup_f32(&self, x: f32) -> f32 {
        let x = crate::color_f32::sat_unit(x);
        let last = N.saturating_sub(1);
        let Some(&last_knot) = self.knots.get(last) else {
            return 0.0;
        };
        if last == 0 || x >= 1.0 {
            return last_knot.to_f32();
        }
        if x <= 0.0 {
            return match self.knots.first() {
                Some(&k) => k.to_f32(),
                None => 0.0,
            };
        }
        let segs = last as f32;
        let pos = x * segs;
        let mut idx = pos as usize;
        if idx >= last {
            idx = last.saturating_sub(1);
        }
        let Some(&a_u) = self.knots.get(idx) else {
            return last_knot.to_f32();
        };
        let b_u = match self.knots.get(idx.saturating_add(1)) {
            Some(&k) => k,
            None => a_u,
        };
        let frac = crate::color_f32::sat_unit(pos - (idx as f32));
        crate::color_f32::lerp_unit(a_u.to_f32(), b_u.to_f32(), frac)
    }

    /// Per-channel `f32` encode: `Linear<S>` → `Encoded<S>`.
    #[cfg(feature = "f32")]
    #[must_use]
    pub fn encode_f32<S: ColorSpace>(
        &self,
        color: crate::ColorF32<S, Linear>,
    ) -> crate::ColorF32<S, Encoded> {
        let [c0, c1, c2] = color.ch;
        crate::ColorF32::new([
            self.lookup_f32(c0),
            self.lookup_f32(c1),
            self.lookup_f32(c2),
        ])
    }

    /// Per-channel `f32` decode: `Encoded<S>` → `Linear<S>`.
    #[cfg(feature = "f32")]
    #[must_use]
    pub fn decode_f32<S: ColorSpace>(
        &self,
        color: crate::ColorF32<S, Encoded>,
    ) -> crate::ColorF32<S, Linear> {
        let [c0, c1, c2] = color.ch;
        crate::ColorF32::new([
            self.lookup_f32(c0),
            self.lookup_f32(c1),
            self.lookup_f32(c2),
        ])
    }
}

/// Piecewise-linear color stops. Sampled with a UQ0.16 parameter.
pub struct Gradient<S: ColorSpace, E: crate::encoding::Encoding, const N: usize> {
    stops: [Color<S, E>; N],
    _pd: PhantomData<fn() -> (S, E)>,
}

impl<S: ColorSpace, E: crate::encoding::Encoding, const N: usize> Copy for Gradient<S, E, N> {}

impl<S: ColorSpace, E: crate::encoding::Encoding, const N: usize> Clone for Gradient<S, E, N> {
    fn clone(&self) -> Self {
        *self
    }
}

impl<S: ColorSpace, const N: usize> Gradient<S, Linear, N> {
    /// Construct from `N` linear color stops (`N >= 2`).
    #[must_use]
    pub const fn from_stops(stops: [Color<S, Linear>; N]) -> Self {
        const { assert!(N >= 2, "Gradient requires at least two stops") };
        Self {
            stops,
            _pd: PhantomData,
        }
    }

    /// Sample the gradient. `0` is the first stop, `65535` is the last.
    #[must_use]
    pub const fn sample(&self, t: Q0_16) -> Color<S, Linear> {
        sample_stops(&self.stops, t)
    }
}

impl<S: ColorSpace + Perceptual, const N: usize> Gradient<S, Encoded, N> {
    /// Construct from encoded perceptual color stops (`N >= 2`).
    #[must_use]
    pub const fn from_stops(stops: [Color<S, Encoded>; N]) -> Self {
        const { assert!(N >= 2, "Gradient requires at least two stops") };
        Self {
            stops,
            _pd: PhantomData,
        }
    }

    /// Sample the encoded perceptual gradient.
    #[must_use]
    pub const fn sample(&self, t: Q0_16) -> Color<S, Encoded> {
        sample_stops(&self.stops, t)
    }
}

/// `t` is [`Q0_16`] and `segs` is saturated into [`u32`] below, so `pos`
/// stays in [`u32`] — no 64-bit multiply, and no divide — for every `N` a
/// `const` stops array can plausibly hold. `N` has no compile-time upper
/// bound (only `N >= 2`), so the cast from `usize` saturates instead of
/// wrapping: a stops array past `u32::MAX + 1` entries is already outside
/// what a `no_alloc` target could hold, but this keeps the position math
/// gracefully wrong rather than silently wrapped for that unreachable case.
#[allow(clippy::indexing_slicing)] // idx is proven `<= N-2`; idx+1 is `<= N-1`.
const fn sample_stops<S: ColorSpace, E: crate::encoding::Encoding, const N: usize>(
    stops: &[Color<S, E>; N],
    t: Q0_16,
) -> Color<S, E> {
    let last = N.saturating_sub(1);
    let segs = if last > u32::MAX as usize {
        u32::MAX
    } else {
        last as u32
    };
    // `div_65535` is exact up to `65535 * 65535`; clamping `pos` keeps the
    // unreachable `N > 65536` case gracefully wrong rather than silently
    // outside the proven range, exactly as `saturating_mul` does above.
    let pos = match (t.to_raw() as u32).saturating_mul(segs) {
        p if p > 65535 * 65535 => 65535 * 65535,
        p => p,
    };
    let hi = last.saturating_sub(1) as u32;
    let raw = crate::arith::div_65535(pos);
    let idx_u = if raw < hi { raw } else { hi };
    let rem = crate::arith::rem_65535(pos, idx_u);
    let idx = idx_u as usize;
    lerp_color(
        stops[idx],
        stops[idx.saturating_add(1)],
        Q0_16::from_raw(rem),
    )
}

const fn lerp_color<S: ColorSpace, E: crate::encoding::Encoding>(
    a: Color<S, E>,
    b: Color<S, E>,
    t: Q0_16,
) -> Color<S, E> {
    let [a0, a1, a2] = a.ch;
    let [b0, b1, b2] = b.ch;
    Color::new([
        lerp_q0_16(a0, b0, t),
        lerp_q0_16(a1, b1, t),
        lerp_q0_16(a2, b2, t),
    ])
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::space::Srgb;

    fn ramp17() -> InterpLut<17> {
        let mut knots = [0u16; 17];
        for (i, slot) in knots.iter_mut().enumerate() {
            let n = i as u32;
            *slot = match n.saturating_mul(65535).checked_div(16) {
                Some(v) if v <= u32::from(u16::MAX) => v as u16,
                _ => 0,
            };
        }
        InterpLut::from_knots(Q0_16::array_from_raw(knots), 0)
    }

    #[test]
    fn endpoints_match_knots() {
        let lut = ramp17();
        assert_eq!(lut.lookup(Q0_16::ZERO), Q0_16::ZERO);
        assert_eq!(lut.lookup(Q0_16::ONE), Q0_16::ONE);
    }

    #[test]
    fn midpoint_between_first_knots() {
        let lut = InterpLut::<17>::from_knots(
            {
                let mut k = [0u16; 17];
                if let Some(slot) = k.get_mut(1) {
                    *slot = 1000;
                }
                Q0_16::array_from_raw(k)
            },
            0,
        );
        let rem = match (2048u64)
            .saturating_mul(16)
            .checked_rem(u64::from(u16::MAX))
        {
            Some(r) if r <= u64::from(u16::MAX) => r as u16,
            _ => 0,
        };
        let y = lut.lookup(Q0_16::from_raw(2048));
        assert_eq!(
            y,
            lerp_q0_16(Q0_16::ZERO, Q0_16::from_raw(1000), Q0_16::from_raw(rem))
        );
    }

    #[test]
    fn increasing_table_is_monotonic() {
        let lut = ramp17();
        let mut prev = lut.lookup(Q0_16::ZERO);
        for x in 1..=u16::MAX {
            let y = lut.lookup(Q0_16::from_raw(x));
            assert!(y >= prev, "x={x} y={y:?} prev={prev:?}");
            prev = y;
        }
    }

    #[test]
    fn decreasing_table_is_monotonic() {
        let mut knots = [0u16; 17];
        for (i, slot) in knots.iter_mut().enumerate() {
            let n = 16u32.saturating_sub(i as u32);
            *slot = match n.saturating_mul(65535).checked_div(16) {
                Some(v) if v <= u32::from(u16::MAX) => v as u16,
                _ => 0,
            };
        }
        let lut = InterpLut::from_knots(Q0_16::array_from_raw(knots), 0);
        let mut prev = lut.lookup(Q0_16::ZERO);
        for x in 1..=u16::MAX {
            let y = lut.lookup(Q0_16::from_raw(x));
            assert!(y <= prev, "x={x} y={y:?} prev={prev:?}");
            prev = y;
        }
    }

    #[test]
    fn lerp_endpoints() {
        let a = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([0, 0, 0]));
        let b = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([100, 200, 300]));
        assert_eq!(a.lerp(b, Q0_16::ZERO), a);
        assert_eq!(a.lerp(b, Q0_16::ONE), b);
    }

    #[test]
    fn gradient_endpoints() {
        let g = Gradient::<Srgb, Linear, 2>::from_stops([
            Color::new(Q0_16::array_from_raw([0, 0, 0])),
            Color::new(Q0_16::array_from_raw([10, 20, 30])),
        ]);
        assert_eq!(g.sample(Q0_16::ZERO).ch, Q0_16::array_from_raw([0, 0, 0]));
        assert_eq!(g.sample(Q0_16::ONE).ch, Q0_16::array_from_raw([10, 20, 30]));
    }
}