Skip to main content

animato_color/
spaces.rs

1//! Color-space interpolation wrappers.
2
3use animato_core::Interpolate;
4use palette::{FromColor, IntoColor, Lab, LinSrgb, Mix, Oklch};
5
6#[cfg(feature = "serde")]
7use serde::{Deserialize, Serialize};
8
9/// Interpolates a color through CIE L*a*b* space.
10///
11/// Lab interpolation is useful for perceptually smoother color transitions
12/// than direct gamma-encoded RGB interpolation.
13#[derive(Clone, Copy, Debug, PartialEq)]
14#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
15pub struct InLab<C>(pub C);
16
17/// Interpolates a color through Oklch space.
18///
19/// Oklch is a modern perceptual cylindrical color space with lightness,
20/// chroma, and hue components.
21#[derive(Clone, Copy, Debug, PartialEq)]
22#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
23pub struct InOklch<C>(pub C);
24
25/// Interpolates a color through linear-light sRGB.
26///
27/// Linear interpolation avoids blending directly in gamma-encoded sRGB.
28#[derive(Clone, Copy, Debug, PartialEq)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub struct InLinear<C>(pub C);
31
32impl<C> InLab<C> {
33    /// Wrap a color for Lab interpolation.
34    #[inline]
35    pub const fn new(color: C) -> Self {
36        Self(color)
37    }
38
39    /// Return a shared reference to the wrapped color.
40    #[inline]
41    pub const fn as_inner(&self) -> &C {
42        &self.0
43    }
44
45    /// Consume the wrapper and return the wrapped color.
46    #[inline]
47    pub fn into_inner(self) -> C {
48        self.0
49    }
50}
51
52impl<C> InOklch<C> {
53    /// Wrap a color for Oklch interpolation.
54    #[inline]
55    pub const fn new(color: C) -> Self {
56        Self(color)
57    }
58
59    /// Return a shared reference to the wrapped color.
60    #[inline]
61    pub const fn as_inner(&self) -> &C {
62        &self.0
63    }
64
65    /// Consume the wrapper and return the wrapped color.
66    #[inline]
67    pub fn into_inner(self) -> C {
68        self.0
69    }
70}
71
72impl<C> InLinear<C> {
73    /// Wrap a color for linear-light sRGB interpolation.
74    #[inline]
75    pub const fn new(color: C) -> Self {
76        Self(color)
77    }
78
79    /// Return a shared reference to the wrapped color.
80    #[inline]
81    pub const fn as_inner(&self) -> &C {
82        &self.0
83    }
84
85    /// Consume the wrapper and return the wrapped color.
86    #[inline]
87    pub fn into_inner(self) -> C {
88        self.0
89    }
90}
91
92impl<C> Interpolate for InLab<C>
93where
94    C: Clone + IntoColor<Lab> + FromColor<Lab> + 'static,
95{
96    #[inline]
97    fn lerp(&self, other: &Self, t: f32) -> Self {
98        let t = clamp01(t);
99        let start: Lab = self.0.clone().into_color();
100        let end: Lab = other.0.clone().into_color();
101        Self(C::from_color(start.mix(end, t)))
102    }
103}
104
105impl<C> Interpolate for InOklch<C>
106where
107    C: Clone + IntoColor<Oklch> + FromColor<Oklch> + 'static,
108{
109    #[inline]
110    fn lerp(&self, other: &Self, t: f32) -> Self {
111        let t = clamp01(t);
112        let start: Oklch = self.0.clone().into_color();
113        let end: Oklch = other.0.clone().into_color();
114        Self(C::from_color(start.mix(end, t)))
115    }
116}
117
118impl<C> Interpolate for InLinear<C>
119where
120    C: Clone + IntoColor<LinSrgb> + FromColor<LinSrgb> + 'static,
121{
122    #[inline]
123    fn lerp(&self, other: &Self, t: f32) -> Self {
124        let t = clamp01(t);
125        let start: LinSrgb = self.0.clone().into_color();
126        let end: LinSrgb = other.0.clone().into_color();
127        Self(C::from_color(start.mix(end, t)))
128    }
129}
130
131#[inline]
132fn clamp01(t: f32) -> f32 {
133    t.clamp(0.0, 1.0)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use palette::Srgb;
140
141    fn approx(a: f32, b: f32) -> bool {
142        (a - b).abs() <= 0.0001
143    }
144
145    fn assert_rgb_close(a: Srgb, b: Srgb) {
146        assert!(approx(a.red, b.red), "red: {} != {}", a.red, b.red);
147        assert!(
148            approx(a.green, b.green),
149            "green: {} != {}",
150            a.green,
151            b.green
152        );
153        assert!(approx(a.blue, b.blue), "blue: {} != {}", a.blue, b.blue);
154    }
155
156    #[test]
157    fn lab_interpolation_keeps_endpoints() {
158        let red = Srgb::new(1.0, 0.0, 0.0);
159        let blue = Srgb::new(0.0, 0.0, 1.0);
160
161        assert_rgb_close(
162            InLab::new(red).lerp(&InLab::new(blue), 0.0).into_inner(),
163            red,
164        );
165        assert_rgb_close(
166            InLab::new(red).lerp(&InLab::new(blue), 1.0).into_inner(),
167            blue,
168        );
169    }
170
171    #[test]
172    fn lab_midpoint_between_red_and_blue_is_not_muddy_brown() {
173        let red = InLab::new(Srgb::new(1.0, 0.0, 0.0));
174        let blue = InLab::new(Srgb::new(0.0, 0.0, 1.0));
175        let midpoint = red.lerp(&blue, 0.5).into_inner();
176
177        assert!(midpoint.red > 0.45);
178        assert!(midpoint.blue > 0.45);
179        assert!(midpoint.green < 0.35);
180    }
181
182    #[test]
183    fn linear_and_lab_midpoints_differ() {
184        let red = Srgb::new(1.0, 0.0, 0.0);
185        let blue = Srgb::new(0.0, 0.0, 1.0);
186
187        let lab = InLab::new(red).lerp(&InLab::new(blue), 0.5).into_inner();
188        let linear = InLinear::new(red)
189            .lerp(&InLinear::new(blue), 0.5)
190            .into_inner();
191
192        assert!((lab.red - linear.red).abs() > 0.01 || (lab.blue - linear.blue).abs() > 0.01);
193    }
194
195    #[test]
196    fn oklch_midpoint_is_finite() {
197        let red = InOklch::new(Srgb::new(1.0, 0.0, 0.0));
198        let blue = InOklch::new(Srgb::new(0.0, 0.0, 1.0));
199        let midpoint = red.lerp(&blue, 0.5).into_inner();
200
201        assert!(midpoint.red.is_finite());
202        assert!(midpoint.green.is_finite());
203        assert!(midpoint.blue.is_finite());
204        assert!((0.0..=1.0).contains(&midpoint.red));
205        assert!((0.0..=1.0).contains(&midpoint.green));
206        assert!((0.0..=1.0).contains(&midpoint.blue));
207    }
208
209    #[test]
210    fn interpolation_factor_is_clamped() {
211        let red = Srgb::new(1.0, 0.0, 0.0);
212        let blue = Srgb::new(0.0, 0.0, 1.0);
213
214        let before = InLinear::new(red)
215            .lerp(&InLinear::new(blue), -1.0)
216            .into_inner();
217        let after = InLinear::new(red)
218            .lerp(&InLinear::new(blue), 2.0)
219            .into_inner();
220
221        assert_rgb_close(before, red);
222        assert_rgb_close(after, blue);
223    }
224}