ph-color 0.1.1

Fixed-point no_std color math for embedded targets: conversion, transfer functions, matrices, gain, and interpolation
Documentation
//! Typed 3×3 matrix from linear `Src` to linear `Dst`.
//!
//! Coefficients are baked Q4.28. This crate applies them; it does not invert,
//! adapt, or solve.

use core::marker::PhantomData;

use crate::color::Color;
use crate::encoding::Linear;
use crate::fixed::Q4_28;
use crate::space::ColorSpace;

/// [`Q4_28`] coefficients that convert `Color<Src, Linear>` to `Color<Dst, Linear>`.
pub struct Matrix3<Src: ColorSpace, Dst: ColorSpace> {
    coefs: [[Q4_28; 3]; 3],
    _pd: PhantomData<fn() -> (Src, Dst)>,
}

impl<Src: ColorSpace, Dst: ColorSpace> Copy for Matrix3<Src, Dst> {}

impl<Src: ColorSpace, Dst: ColorSpace> Clone for Matrix3<Src, Dst> {
    fn clone(&self) -> Self {
        *self
    }
}

impl<Src: ColorSpace, Dst: ColorSpace> PartialEq for Matrix3<Src, Dst> {
    fn eq(&self, other: &Self) -> bool {
        self.coefs == other.coefs
    }
}

impl<Src: ColorSpace, Dst: ColorSpace> Eq for Matrix3<Src, Dst> {}

impl<Src: ColorSpace, Dst: ColorSpace> core::fmt::Debug for Matrix3<Src, Dst> {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("Matrix3")
            .field("coefs", &self.coefs)
            .finish()
    }
}

impl<Src: ColorSpace, Dst: ColorSpace> Matrix3<Src, Dst> {
    /// Wrap baked [`Q4_28`] coefficients. Target code does not derive them.
    #[must_use]
    pub const fn from_q428(coefs: [[Q4_28; 3]; 3]) -> Self {
        Self {
            coefs,
            _pd: PhantomData,
        }
    }

    /// Apply to a linear source: one `i64` accumulate per row, then one
    /// shift-round-saturate. There is no inverse or chromatic adaptation.
    #[must_use]
    pub const fn apply(&self, color: Color<Src, Linear>) -> Color<Dst, Linear> {
        let [row0, row1, row2] = self.coefs;
        Color::new([
            crate::arith::mul_acc3(row0, color.ch),
            crate::arith::mul_acc3(row1, color.ch),
            crate::arith::mul_acc3(row2, color.ch),
        ])
    }

    /// Apply with `f32` mul/add of Q4.28 coefficients decoded as
    /// `coef as f32 / 2^28`. Saturates each channel to `0.0..=1.0`.
    ///
    /// Additive: does not change [`Self::apply`]. Error versus W7 goldens is
    /// at most [`crate::F32_MAX_ERR_LSB`].
    #[cfg(feature = "f32")]
    #[must_use]
    pub fn apply_f32(&self, color: crate::ColorF32<Src, Linear>) -> crate::ColorF32<Dst, Linear> {
        let [row0, row1, row2] = self.coefs;
        crate::ColorF32::new([
            crate::color_f32::apply_row_f32(row0, color.ch),
            crate::color_f32::apply_row_f32(row1, color.ch),
            crate::color_f32::apply_row_f32(row2, color.ch),
        ])
    }
}

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

    #[test]
    fn identity_leaves_linear_color_unchanged() {
        let one = Q4_28::ONE;
        let zero = Q4_28::ZERO;
        let m = Matrix3::<Srgb, Srgb>::from_q428([
            [one, zero, zero],
            [zero, one, zero],
            [zero, zero, one],
        ]);
        let c = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([12, 34, 56]));
        assert_eq!(m.apply(c), c);
    }
}