ph-color 0.1.0

Fixed-point no_std color math for embedded targets: conversion, transfer functions, matrices, gain, and interpolation
Documentation
//! Fixed-point value types (see `docs/NUMERICS.md`): [`Q4_28`], the single signed
//! format for every matrix and gain coefficient, and [`Q0_16`], the single
//! unsigned format for every color channel and interpolation parameter.

/// A signed Q4.28 fixed-point value: `raw / 2^28`.
///
/// 28 fractional bits, backed by [`i32`]. `1.0` is [`Q4_28::ONE`]; the
/// contractual range is `±8.0` (the worst-case gamut matrix coefficient),
/// one format for every [`crate::Matrix3`] / [`crate::Gain`] coefficient.
///
/// This type names that meaning once instead of leaving it to a doc comment
/// on every module that touches a coefficient. `ph-color-bake` derives raw
/// Q4.28 `i32`s on the host and hands them to [`Self::from_raw`] /
/// [`Self::mat3_from_raw`] at the boundary, so both crates share one
/// definition of "what a Q4.28 value is" instead of each repeating the
/// convention in prose.
///
/// No arithmetic operators (`Add`, `Mul`, ...) are implemented on purpose.
/// Every accumulation in this crate is a specific saturating kernel (the
/// crate-private `arith` module), not a generic `+`/`*`; adding operators
/// here would invite exactly the unchecked arithmetic the numeric guarantees forbid.
/// The `saturating_add` / `saturating_sub` methods below exist only to
/// compose coefficients (e.g. `ONE` minus a small trim) the same explicit
/// way every other arithmetic path in this crate does.
#[repr(transparent)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Q4_28(i32);

impl Q4_28 {
    /// Fractional bits.
    pub const FRAC_BITS: u32 = 28;

    /// `1.0` (`1 << 28`).
    pub const ONE: Self = Self(1 << Self::FRAC_BITS);

    /// `0.0`.
    pub const ZERO: Self = Self(0);

    /// Wrap a raw Q4.28 `i32`.
    ///
    /// Not range-checked against `±8`: every crate-internal arithmetic path
    /// that consumes a [`Q4_28`] saturates, so an out-of-contract magnitude
    /// degrades to a saturated channel, never UB or a panic.
    #[must_use]
    pub const fn from_raw(raw: i32) -> Self {
        Self(raw)
    }

    /// The raw Q4.28 `i32`.
    #[must_use]
    pub const fn to_raw(self) -> i32 {
        self.0
    }

    /// Wrap a raw Q4.28 row. A bulk convenience for baked tables written as
    /// compact integer literals (see [`Self::from_raw`]).
    #[must_use]
    pub const fn row_from_raw(raw: [i32; 3]) -> [Self; 3] {
        [Self(raw[0]), Self(raw[1]), Self(raw[2])]
    }

    /// Wrap a raw Q4.28 3×3 matrix. See [`Self::row_from_raw`].
    #[must_use]
    pub const fn mat3_from_raw(raw: [[i32; 3]; 3]) -> [[Self; 3]; 3] {
        [
            Self::row_from_raw(raw[0]),
            Self::row_from_raw(raw[1]),
            Self::row_from_raw(raw[2]),
        ]
    }

    /// Saturating add.
    #[must_use]
    pub const fn saturating_add(self, rhs: Self) -> Self {
        Self(self.0.saturating_add(rhs.0))
    }

    /// Saturating subtract.
    #[must_use]
    pub const fn saturating_sub(self, rhs: Self) -> Self {
        Self(self.0.saturating_sub(rhs.0))
    }

    /// Saturating negate.
    #[must_use]
    pub const fn saturating_neg(self) -> Self {
        Self(self.0.saturating_neg())
    }
}

#[cfg(feature = "f32")]
impl Q4_28 {
    /// Decode to `f32` (`raw as f32 / 2^28`).
    #[must_use]
    pub const fn to_f32(self) -> f32 {
        (self.0 as f32) / (Self::ONE.0 as f32)
    }
}

/// An unsigned Q0.16 fixed-point value: `raw / 65535`. `0` is 0.0;
/// [`Q0_16::ONE`] (`65535`) is 1.0.
///
/// This is the single channel format for [`crate::Color`] and every
/// interpolation parameter in this crate (see `docs/NUMERICS.md`): matches the HUB75
/// wire format (16-bit linear) so the working format and the wire format
/// stay identical.
///
/// Constructors that take a compact literal array or table (e.g.
/// [`crate::Color::new`], [`crate::InterpLut::from_knots`]) take [`Q0_16`]
/// directly; use [`Self::array_from_raw`] to wrap a `[u16; N]` literal in
/// one call instead of element by element.
///
/// No arithmetic operators are implemented, for the same reason as
/// [`Q4_28`]: every accumulation in this crate is a specific saturating
/// kernel (the crate-private `arith` module), not a generic `+`/`-`.
#[repr(transparent)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub struct Q0_16(u16);

impl Q0_16 {
    /// `0.0`.
    pub const ZERO: Self = Self(0);

    /// `1.0` (`u16::MAX`).
    pub const ONE: Self = Self(u16::MAX);

    /// Wrap a raw UQ0.16 `u16`.
    #[must_use]
    pub const fn from_raw(raw: u16) -> Self {
        Self(raw)
    }

    /// The raw UQ0.16 `u16`.
    #[must_use]
    pub const fn to_raw(self) -> u16 {
        self.0
    }

    /// Wrap a raw `[u16; N]` literal array or table. A bulk convenience,
    /// see [`Self::from_raw`].
    #[must_use]
    #[allow(clippy::indexing_slicing)] // `i` is proven `< N` by the loop guard.
    pub const fn array_from_raw<const N: usize>(raw: [u16; N]) -> [Self; N] {
        let mut out = [Self::ZERO; N];
        let mut i = 0;
        while i < N {
            out[i] = Self(raw[i]);
            i = i.saturating_add(1);
        }
        out
    }

    /// Saturating add.
    #[must_use]
    pub const fn saturating_add(self, rhs: Self) -> Self {
        Self(self.0.saturating_add(rhs.0))
    }

    /// Saturating subtract.
    #[must_use]
    pub const fn saturating_sub(self, rhs: Self) -> Self {
        Self(self.0.saturating_sub(rhs.0))
    }
}

#[cfg(feature = "f32")]
impl Q0_16 {
    /// Decode to unit `f32` (`raw as f32 / 65535`).
    #[must_use]
    pub const fn to_f32(self) -> f32 {
        (self.0 as f32) / (Self::ONE.0 as f32)
    }
}

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

    #[test]
    fn one_is_the_expected_bit_pattern() {
        assert_eq!(Q4_28::ONE.to_raw(), 1 << 28);
        assert_eq!(Q4_28::ZERO.to_raw(), 0);
    }

    #[test]
    fn raw_round_trips() {
        assert_eq!(Q4_28::from_raw(-123).to_raw(), -123);
        assert_eq!(Q4_28::from_raw(i32::MAX).to_raw(), i32::MAX);
    }

    #[test]
    fn saturating_ops_never_wrap() {
        assert_eq!(
            Q4_28::from_raw(i32::MAX)
                .saturating_add(Q4_28::ONE)
                .to_raw(),
            i32::MAX
        );
        assert_eq!(
            Q4_28::from_raw(i32::MIN)
                .saturating_sub(Q4_28::ONE)
                .to_raw(),
            i32::MIN
        );
    }

    #[test]
    fn row_and_mat3_from_raw_wrap_elementwise() {
        assert_eq!(
            Q4_28::row_from_raw([1, 2, 3]),
            [Q4_28::from_raw(1), Q4_28::from_raw(2), Q4_28::from_raw(3)]
        );
        assert_eq!(
            Q4_28::mat3_from_raw([[1, 0, 0], [0, 1, 0], [0, 0, 1]]),
            [
                Q4_28::row_from_raw([1, 0, 0]),
                Q4_28::row_from_raw([0, 1, 0]),
                Q4_28::row_from_raw([0, 0, 1]),
            ]
        );
    }

    #[cfg(feature = "f32")]
    #[test]
    fn to_f32_decodes_one_and_zero() {
        assert_eq!(Q4_28::ONE.to_f32(), 1.0);
        assert_eq!(Q4_28::ZERO.to_f32(), 0.0);
    }

    #[test]
    fn q0_16_one_is_the_expected_bit_pattern() {
        assert_eq!(Q0_16::ONE.to_raw(), u16::MAX);
        assert_eq!(Q0_16::ZERO.to_raw(), 0);
    }

    #[test]
    fn q0_16_raw_round_trips() {
        assert_eq!(Q0_16::from_raw(123).to_raw(), 123);
        assert_eq!(Q0_16::from_raw(u16::MAX).to_raw(), u16::MAX);
    }

    #[test]
    fn q0_16_saturating_ops_never_wrap() {
        assert_eq!(
            Q0_16::from_raw(u16::MAX)
                .saturating_add(Q0_16::ONE)
                .to_raw(),
            u16::MAX
        );
        assert_eq!(Q0_16::from_raw(0).saturating_sub(Q0_16::ONE).to_raw(), 0);
    }

    #[test]
    fn q0_16_array_from_raw_wraps_elementwise() {
        assert_eq!(
            Q0_16::array_from_raw([1u16, 2, 3]),
            [Q0_16::from_raw(1), Q0_16::from_raw(2), Q0_16::from_raw(3)]
        );
        assert_eq!(Q0_16::array_from_raw::<0>([]), []);
    }

    #[test]
    fn q0_16_orders_like_its_raw_value() {
        assert!(Q0_16::ZERO < Q0_16::ONE);
        assert!(Q0_16::from_raw(100) < Q0_16::from_raw(200));
    }

    #[cfg(feature = "f32")]
    #[test]
    fn q0_16_to_f32_decodes_one_and_zero() {
        assert_eq!(Q0_16::ONE.to_f32(), 1.0);
        assert_eq!(Q0_16::ZERO.to_f32(), 0.0);
    }
}