ph-color 0.1.1

Fixed-point no_std color math for embedded targets: conversion, transfer functions, matrices, gain, and interpolation
Documentation
//! Typed per-channel gain on linear colors of one space.
//!
//! Coefficients are baked Q4.28. Application saturates; it never wraps.

use core::marker::PhantomData;

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

/// [`Q4_28`] per-channel gain for `Color<S, Linear>` only.
pub struct Gain<S: ColorSpace> {
    coefs: [Q4_28; 3],
    _pd: PhantomData<fn() -> S>,
}

impl<S: ColorSpace> Copy for Gain<S> {}

impl<S: ColorSpace> Clone for Gain<S> {
    fn clone(&self) -> Self {
        *self
    }
}

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

impl<S: ColorSpace> Eq for Gain<S> {}

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

impl<S: ColorSpace> Gain<S> {
    /// Wrap baked [`Q4_28`] per-channel coefficients.
    #[must_use]
    pub const fn from_q428(coefs: [Q4_28; 3]) -> Self {
        Self {
            coefs,
            _pd: PhantomData,
        }
    }

    /// Apply to a linear color: one Q4.28 × UQ0.16 product per channel, then
    /// one shift-round-saturate. Saturates; never wraps.
    #[must_use]
    pub const fn apply(&self, color: Color<S, Linear>) -> Color<S, Linear> {
        let [g0, g1, g2] = self.coefs;
        let [c0, c1, c2] = color.ch;
        Color::new([
            crate::arith::shift_round_sat_q0_16(crate::arith::mul_acc(0, g0, c0)),
            crate::arith::shift_round_sat_q0_16(crate::arith::mul_acc(0, g1, c1)),
            crate::arith::shift_round_sat_q0_16(crate::arith::mul_acc(0, g2, c2)),
        ])
    }

    /// Apply with `f32` mul 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<S, Linear>) -> crate::ColorF32<S, Linear> {
        let [g0, g1, g2] = self.coefs;
        let [c0, c1, c2] = color.ch;
        crate::ColorF32::new([
            crate::color_f32::sat_unit(g0.to_f32() * crate::color_f32::sat_unit(c0)),
            crate::color_f32::sat_unit(g1.to_f32() * crate::color_f32::sat_unit(c1)),
            crate::color_f32::sat_unit(g2.to_f32() * crate::color_f32::sat_unit(c2)),
        ])
    }
}

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

    #[test]
    fn identity_gain_is_unchanged() {
        let one = Q4_28::ONE;
        let g = Gain::<Srgb>::from_q428([one, one, one]);
        let c = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([10, 20, 30]));
        assert_eq!(g.apply(c), c);
    }

    #[test]
    fn gain_two_saturates_full_scale() {
        let two = Q4_28::ONE.saturating_add(Q4_28::ONE);
        let g = Gain::<Srgb>::from_q428([two, two, two]);
        let c = Color::<Srgb, Linear>::new(Q0_16::array_from_raw([40_000, 65535, 1]));
        let out = g.apply(c);
        assert_eq!(out.ch, Q0_16::array_from_raw([65535, 65535, 2]));
    }

    #[cfg(feature = "f32")]
    #[test]
    fn apply_f32_clamps_negative_channels_before_signed_gain() {
        let neg_one = Q4_28::ONE.saturating_neg();
        let g = Gain::<Srgb>::from_q428([neg_one, neg_one, neg_one]);
        let c = crate::ColorF32::<Srgb, Linear>::new([-1.0, f32::NEG_INFINITY, 0.25]);
        assert_eq!(g.apply_f32(c).ch, [0.0, 0.0, 0.0]);
    }
}