ph-color 0.1.0

Fixed-point no_std color math for embedded targets: conversion, transfer functions, matrices, gain, and interpolation
Documentation
//! Policy-free bit-depth reduction for the dither seam.
//!
//! Color ends at quantization. These functions truncate a [`Q0_16`] channel
//! toward zero and return the discarded residual. Rounding, noise, and
//! dither belong on the far side of the seam. The `BITS`-wide code itself is
//! not [`Q0_16`] — it is a narrower, differently-scaled domain that starts
//! here and ends at [`expand`].

use crate::fixed::Q0_16;

/// Truncate a [`Q0_16`] channel toward zero to a `BITS`-wide code.
///
/// `BITS` must be in `1..=16`. The quantized value `q` occupies the low
/// `BITS` bits (`0..=2^BITS-1`). [`expand`] reconstructs the truncated
/// [`Q0_16`] value (zeros in discarded bits). The residual is the signed
/// error in UQ0.16 units: `v - expand::<BITS>(q)`.
///
/// For unsigned channels, truncation toward zero never yields a negative
/// residual; the type is [`i32`] so a later dither stage can carry a signed
/// error without this crate choosing a rounding policy.
#[must_use]
pub const fn quantize<const BITS: u32>(v: Q0_16) -> (u16, i32) {
    const { assert!(BITS >= 1 && BITS <= 16, "BITS must be in 1..=16") };
    let shift = 16u32.saturating_sub(BITS);
    let q = match v.to_raw().checked_shr(shift) {
        Some(code) => code,
        None => 0,
    };
    let residual = (v.to_raw() as i32).saturating_sub(expand::<BITS>(q).to_raw() as i32);
    (q, residual)
}

/// Reconstruct a [`Q0_16`] value from a `BITS`-wide quantized code.
///
/// `BITS` must be in `1..=16`. Only the low `BITS` bits of `q` are used; they
/// are shifted into the high bits of the [`Q0_16`] result so discarded bits
/// are zeros. This is lossless with respect to [`quantize`]:
/// `expand::<BITS>(quantize::<BITS>(v).0) + residual == v`.
#[must_use]
pub const fn expand<const BITS: u32>(q: u16) -> Q0_16 {
    const { assert!(BITS >= 1 && BITS <= 16, "BITS must be in 1..=16") };
    let shift = 16u32.saturating_sub(BITS);
    let code = match 1u16.checked_shl(BITS) {
        None => q,
        Some(width) => q & width.saturating_sub(1),
    };
    match code.checked_shl(shift) {
        Some(v) => Q0_16::from_raw(v),
        None => Q0_16::ZERO,
    }
}

#[cfg(test)]
mod tests {
    use super::{Q0_16, expand, quantize};

    fn identity_holds<const BITS: u32>(v: u16) {
        let (q, residual) = quantize::<BITS>(Q0_16::from_raw(v));
        let reconstructed = (expand::<BITS>(q).to_raw() as i32).saturating_add(residual);
        assert_eq!(reconstructed, i32::from(v), "BITS={BITS} v={v}");
    }

    #[test]
    fn edges_identity() {
        identity_holds::<1>(0);
        identity_holds::<1>(1);
        identity_holds::<1>(65535);
        identity_holds::<8>(0);
        identity_holds::<8>(1);
        identity_holds::<8>(65535);
        identity_holds::<16>(0);
        identity_holds::<16>(1);
        identity_holds::<16>(65535);
    }

    #[test]
    fn bits16_is_identity_with_zero_residual() {
        assert_eq!(quantize::<16>(Q0_16::from_raw(0)), (0, 0));
        assert_eq!(quantize::<16>(Q0_16::from_raw(1)), (1, 0));
        assert_eq!(quantize::<16>(Q0_16::from_raw(65535)), (65535, 0));
        assert_eq!(expand::<16>(0xABCD), Q0_16::from_raw(0xABCD));
    }

    #[test]
    fn truncates_toward_zero_without_rounding() {
        // BITS=8 discards 8 LSBs. 0x8080 is the midpoint of that bin;
        // rounding-to-nearest would bump the 8-bit code from 0x80 to 0x81.
        let (q, residual) = quantize::<8>(Q0_16::from_raw(0x8080));
        assert_eq!(q, 0x80);
        assert_eq!(residual, 0x80);
        assert_eq!(expand::<8>(q), Q0_16::from_raw(0x8000));
        assert_ne!(q, 0x81);
    }

    #[test]
    fn expand_uses_only_the_bits_wide_code() {
        assert_eq!(expand::<8>(0x80), Q0_16::from_raw(0x8000));
        assert_eq!(expand::<8>(0x80FF), Q0_16::from_raw(0xFF00));
    }

    #[test]
    fn exhaustive_identity_all_valid_bits() {
        for v in 0..=u16::MAX {
            identity_holds::<1>(v);
            identity_holds::<2>(v);
            identity_holds::<3>(v);
            identity_holds::<4>(v);
            identity_holds::<5>(v);
            identity_holds::<6>(v);
            identity_holds::<7>(v);
            identity_holds::<8>(v);
            identity_holds::<9>(v);
            identity_holds::<10>(v);
            identity_holds::<11>(v);
            identity_holds::<12>(v);
            identity_holds::<13>(v);
            identity_holds::<14>(v);
            identity_holds::<15>(v);
            identity_holds::<16>(v);
        }
    }
}