icebox 0.0.0

A reusable color math library designed as a companion to cooler.
Documentation
use std::{
    f64::consts::{FRAC_PI_3, TAU},
    hint::unreachable_unchecked,
    marker::PhantomData,
};

use glam::{dvec3, swizzles::*, DVec3};

use crate::{
    color::{Color, ParamColor},
    space::{ColorVecSpace, OpponentSpace, Srgb},
};

pub trait Parameterize {
    type Value: Sized;
}

pub trait FromParameterized<P: Parameterize + ?Sized>: ColorVecSpace {
    fn from_param(param: ParamColor<P>) -> Color<Self>;
}

pub trait IntoParameterized<P: Parameterize + ?Sized>: ColorVecSpace {
    fn into_param(clr: Color<Self>) -> ParamColor<P>;
}

pub trait FromColor<S: IntoParameterized<Self>>: Parameterize {
    fn from_color(clr: Color<S>) -> ParamColor<Self>;
}

impl<T: Parameterize, S: IntoParameterized<Self>> FromColor<S> for T {
    fn from_color(clr: Color<S>) -> ParamColor<Self> { S::into_param(clr) }
}

/// HSV color space, intended for use primarily in compatibility with sRGB.
///
/// **NOTE:** hue is stored in radians, for consistency with Lch and Lsh which
/// use trigonometric functions to compute hue
#[derive(Debug)]
pub struct Hsv;
#[derive(Debug)]
pub struct Lch<S: OpponentSpace>(PhantomData<S>);
#[derive(Debug)]
pub struct Lsh<S: OpponentSpace>(PhantomData<S>);
// TODO
// #[derive(Debug)]
// pub struct Subspace<S: ColorVecSpace>(S::Vector, S::Vector, S::Vector);

impl Parameterize for Hsv {
    type Value = DVec3;
}

impl<S: OpponentSpace> Parameterize for Lch<S> {
    type Value = DVec3;
}

impl<S: OpponentSpace> Parameterize for Lsh<S> {
    type Value = DVec3;
}

impl FromParameterized<Hsv> for Srgb {
    fn from_param(param: ParamColor<Hsv>) -> Color<Self> {
        let hsv = param.into_inner();
        let h = hsv.x.to_degrees().rem_euclid(360.0) / 60.0;
        let c = hsv.y * hsv.z;

        // low-mid-hi
        //  - X is the black point
        //  - Y moves between X and Z with H
        //  - Z is the white point
        let lmh = dvec3(
            hsv.z - c,
            hsv.z - (1.0 - h.rem_euclid(2.0)).abs() * c,
            hsv.z,
        );

        #[allow(clippy::cast_possible_truncation)]
        {
            debug_assert!((0..6).contains(&(h as i16)));
        }

        Color::new(match unsafe { h.to_int_unchecked() } {
            // Waveform:         |R |G |B |
            0_u8 => lmh.zyx(), // #| \  :
            1 => lmh.yzx(),    // /  #| :
            2 => lmh.xzy(),    // :  #| \
            3 => lmh.xyz(),    // :  /  #|
            4 => lmh.yxz(),    // \  :  #|
            5 => lmh.zxy(),    // #| :  /
            _ => unsafe { unreachable_unchecked() },
        })
    }
}

impl IntoParameterized<Hsv> for Srgb {
    fn into_param(clr: Color<Self>) -> ParamColor<Hsv> {
        let rgb = clr.into_inner();
        // See above comment on lmh
        let lmh = {
            let mut vec = rgb;
            vec.as_mut().sort_by(|a, b| a.partial_cmp(b).unwrap());
            vec
        };

        let v = lmh.z;
        let c = v - lmh.x;
        let mid_pct = if c.abs() > 1e-7 {
            (lmh.y - lmh.x) / c
        } else {
            0.0
        };

        debug_assert!((0.0..=1.0).contains(&mid_pct), "mid_pct was {:?}", mid_pct);

        #[allow(clippy::float_cmp)]
        let h = if rgb.x == lmh.z {
            if rgb.z == lmh.x {
                // lmh.zyx()
                debug_assert_eq!(rgb.y, lmh.y);
                mid_pct
            } else {
                // lmh.zxy()
                debug_assert_eq!(rgb.y, lmh.x);
                debug_assert_eq!(rgb.z, lmh.y);
                6.0 - mid_pct
            }
        } else if rgb.x == lmh.x {
            if rgb.y == lmh.z {
                // lmh.xzy()
                debug_assert_eq!(rgb.z, lmh.y);
                2.0 + mid_pct
            } else {
                // lmh.xyz()
                debug_assert_eq!(rgb.y, lmh.y);
                debug_assert_eq!(rgb.z, lmh.z);
                4.0 - mid_pct
            }
        } else {
            debug_assert_eq!(rgb.x, lmh.y);
            if rgb.y == lmh.z {
                // lmh.yzx()
                debug_assert_eq!(rgb.z, lmh.x);
                2.0 - mid_pct
            } else {
                // lmh.yxz()
                debug_assert_eq!(rgb.y, lmh.x);
                debug_assert_eq!(rgb.z, lmh.z);
                4.0 + mid_pct
            }
        } * FRAC_PI_3;

        debug_assert!((0.0..360.0).contains(&h.to_degrees()));

        ParamColor::new(dvec3(h, if v > 1e-7 { c / v } else { 0.0 }, v))
    }
}

fn atan3(y: f64, x: f64) -> f64 {
    let theta = y.atan2(x);

    if theta < 0.0 {
        TAU + theta
    } else {
        theta
    }
}

impl<S: OpponentSpace> FromParameterized<Lch<S>> for S {
    fn from_param(param: ParamColor<Lch<Self>>) -> Color<Self> {
        let lch = param.into_inner();

        S::from_lxy(dvec3(lch.x, lch.y * lch.z.cos(), lch.y * lch.z.sin()))
    }
}

impl<S: OpponentSpace> IntoParameterized<Lch<S>> for S {
    fn into_param(clr: Color<Self>) -> ParamColor<Lch<Self>> {
        let lxy = S::into_lxy(clr);

        ParamColor::new(dvec3(lxy.x, lxy.yz().length(), atan3(lxy.z, lxy.y)))
    }
}

impl<S: OpponentSpace> FromParameterized<Lsh<S>> for S {
    fn from_param(param: ParamColor<Lsh<Self>>) -> Color<Self> {
        let lsh = param.into_inner();
        let c = lsh.x * lsh.y;

        S::from_lxy(dvec3(lsh.x, c * lsh.z.cos(), c * lsh.z.sin()))
    }
}

impl<S: OpponentSpace> IntoParameterized<Lsh<S>> for S {
    fn into_param(clr: Color<Self>) -> ParamColor<Lsh<Self>> {
        let lxy = S::into_lxy(clr);

        ParamColor::new(dvec3(
            lxy.x,
            if lxy.x.abs() > 1e-7 {
                lxy.yz().length() / lxy.x
            } else {
                0.0
            },
            atan3(lxy.z, lxy.y),
        ))
    }
}

#[cfg(test)]
mod test {
    use std::f64::consts::SQRT_2;

    use proptest::prelude::*;

    use super::*;

    const EPSILON: f64 = 1e-7;

    macro_rules! assert_eqv {
        (@prop $a:expr, $b:expr) => {
            assert_eqv!(@prop $a, $b, EPSILON);
        };

        ($a:expr, $b:expr) => {
            assert_eqv!($a, $b, EPSILON);
        };

        ($a:expr, $b:expr, $max:expr) => {
            assert!(
                $a.abs_diff_eq($b, $max),
                "Vectors were not equal by {}: {:?}, {:?}",
                $max,
                &$a,
                &$b
            );
        };

        (@prop $a:expr, $b:expr, $max:expr) => {
            prop_assert!(
                $a.abs_diff_eq($b, $max),
                "Vectors were not equal by {}: {:?}, {:?}",
                $max,
                &$a,
                &$b
            );
        };
    }

    /// Dummy opponent color space for testing Lch and Lsh
    #[derive(Debug)]
    struct Lxy;

    impl ColorVecSpace for Lxy {
        type Vector = DVec3;
    }

    impl OpponentSpace for Lxy {
        fn from_lxy(lxy: DVec3) -> Color<Self> { Color::new(lxy) }

        fn into_lxy(clr: Color<Self>) -> DVec3 { clr.into_inner() }
    }

    // Because .to_radians() couldn't infer the type of {float} literals
    fn r(f: f64) -> f64 { f.to_radians() }

    #[test]
    fn test_hsv() {
        type C = Color<Srgb>;
        type H = ParamColor<Hsv>;

        let red = C::new(dvec3(1.0, 0.0, 0.0));
        let green = C::new(dvec3(0.0, 1.0, 0.0));
        let blue = C::new(dvec3(0.0, 0.0, 1.0));
        let yellow = C::new(dvec3(1.0, 1.0, 0.0));
        let cyan = C::new(dvec3(0.0, 1.0, 1.0));
        let magenta = C::new(dvec3(1.0, 0.0, 1.0));

        let red_h = H::new(dvec3(r(0.0), 1.0, 1.0));
        let green_h = H::new(dvec3(r(120.0), 1.0, 1.0));
        let blue_h = H::new(dvec3(r(240.0), 1.0, 1.0));
        let yellow_h = H::new(dvec3(r(60.0), 1.0, 1.0));
        let cyan_h = H::new(dvec3(r(180.0), 1.0, 1.0));
        let magenta_h = H::new(dvec3(r(300.0), 1.0, 1.0));

        assert_eqv!(red.into_param::<Hsv>(), *red_h);
        assert_eqv!(green.into_param::<Hsv>(), *green_h);
        assert_eqv!(blue.into_param::<Hsv>(), *blue_h);
        assert_eqv!(yellow.into_param::<Hsv>(), *yellow_h);
        assert_eqv!(cyan.into_param::<Hsv>(), *cyan_h);
        assert_eqv!(magenta.into_param::<Hsv>(), *magenta_h);

        assert_eqv!(red_h.into_color::<Srgb>(), *red);
        assert_eqv!(green_h.into_color::<Srgb>(), *green);
        assert_eqv!(blue_h.into_color::<Srgb>(), *blue);
        assert_eqv!(yellow_h.into_color::<Srgb>(), *yellow);
        assert_eqv!(cyan_h.into_color::<Srgb>(), *cyan);
        assert_eqv!(magenta_h.into_color::<Srgb>(), *magenta);

        let redf = C::new(dvec3(0.75, 0.25, 0.25));
        let redf_h = H::new(dvec3(r(0.0), 0.5 / 0.75, 0.75));

        assert_eqv!(redf.into_param::<Hsv>(), *redf_h);
        assert_eqv!(redf_h.into_color::<Srgb>(), *redf);
    }

    #[test]
    fn test_lch_lsh() {
        type C = Color<Lxy>;
        type Lch = super::Lch<Lxy>;
        type Lsh = super::Lsh<Lxy>;
        type Lc = ParamColor<Lch>;
        type Ls = ParamColor<Lsh>;

        let a = C::new(dvec3(1.0, 1.0, 0.0));
        let b = C::new(dvec3(1.0, 0.0, 1.0));
        let c = C::new(dvec3(0.5, 1.0, -1.0));
        let d = C::new(dvec3(0.5, -1.0, 1.0));

        let a_lc = Lc::new(dvec3(1.0, 1.0, r(0.0)));
        let b_lc = Lc::new(dvec3(1.0, 1.0, r(90.0)));
        let c_lc = Lc::new(dvec3(0.5, SQRT_2, r(315.0)));
        let d_lc = Lc::new(dvec3(0.5, SQRT_2, r(135.0)));

        let a_ls = Ls::new(dvec3(1.0, 1.0, r(0.0)));
        let b_ls = Ls::new(dvec3(1.0, 1.0, r(90.0)));
        let c_ls = Ls::new(dvec3(0.5, 2.0 * SQRT_2, r(315.0)));
        let d_ls = Ls::new(dvec3(0.5, 2.0 * SQRT_2, r(135.0)));

        assert_eqv!(a.into_param::<Lch>(), *a_lc);
        assert_eqv!(b.into_param::<Lch>(), *b_lc);
        assert_eqv!(c.into_param::<Lch>(), *c_lc);
        assert_eqv!(d.into_param::<Lch>(), *d_lc);

        assert_eqv!(a.into_param::<Lsh>(), *a_ls);
        assert_eqv!(b.into_param::<Lsh>(), *b_ls);
        assert_eqv!(c.into_param::<Lsh>(), *c_ls);
        assert_eqv!(d.into_param::<Lsh>(), *d_ls);
    }

    proptest! {
        #[test]
        fn test_hsv_roundtrip(r in 0.0..=1.0, g in 0.0..=1.0, b in 0.0..=1.0) {
            let clr = Color::<Srgb>::new(dvec3(r, g, b));

            assert_eqv!(@prop clr, *clr.into_param::<Hsv>().into_color::<Srgb>());
        }

        #[test]
        fn test_lch_roundtrip(l in 0.0..=1.0, x in -1.0..=1.0, y in -1.0..=1.0) {
            let clr = Color::<Lxy>::new(dvec3(l, x, y));

            assert_eqv!(@prop clr, *clr.into_param::<Lch<Lxy>>().into_color::<Lxy>());
        }

        #[test]
        fn test_lch_lsh_correlation(l in 0.0..=1.0, x in -1.0..=1.0, y in -1.0..=1.0) {
            let clr = Color::<Lxy>::new(dvec3(l, x, y));

            let lch = clr.into_param::<Lch<Lxy>>();
            let lsh = clr.into_param::<Lsh<Lxy>>();

            // Luma should be untouched and identical
            assert_eq!(lch.x, clr.x);
            assert_eq!(lch.x, lsh.x);

            // Chroma and saturation should be equal within rounding error
            assert!((lsh.x * lsh.y - lch.y).abs() < EPSILON);

            // Hue uses an identical transform for both parameterizations
            assert_eq!(lch.z, lsh.z);
        }
    }
}