1use crate::{
2 A98Rgb, Channel, Color, DisplayP3, Hsl, Hwb, Lab, Lch, LinearRgb, Oklab, Oklch, ProphotoRgb, Rec2020, Srgb, XyzD50,
3 XyzD65,
4};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum HueInterpolation {
11 #[default]
12 Shorter,
13 Longer,
14 Increasing,
15 Decreasing,
16}
17
18impl HueInterpolation {
19 pub fn delta(self, from: f64, to: f64) -> f64 {
21 let d = to.rem_euclid(360.0) - from.rem_euclid(360.0);
22 match self {
23 Self::Shorter if d > 180.0 => d - 360.0,
24 Self::Shorter if d < -180.0 => d + 360.0,
25 Self::Longer if d > 0.0 && d < 180.0 => d - 360.0,
26 Self::Longer if d > -180.0 && d <= 0.0 => d + 360.0,
27 Self::Increasing if d < 0.0 => d + 360.0,
28 Self::Decreasing if d > 0.0 => d - 360.0,
29 _ => d,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum InterpolationSpace {
40 Srgb,
41 SrgbLinear,
42 DisplayP3,
43 A98Rgb,
44 ProphotoRgb,
45 Rec2020,
46 Lab,
47 Oklab,
48 XyzD50,
49 XyzD65,
50 Hsl(HueInterpolation),
51 Hwb(HueInterpolation),
52 Lch(HueInterpolation),
53 Oklch(HueInterpolation),
54}
55
56impl InterpolationSpace {
57 pub const fn hue_index(self) -> Option<usize> {
59 match self {
60 Self::Hsl(_) | Self::Hwb(_) => Some(0),
61 Self::Lch(_) | Self::Oklch(_) => Some(2),
62 _ => None,
63 }
64 }
65
66 pub const fn hue_interpolation(self) -> Option<HueInterpolation> {
68 match self {
69 Self::Hsl(hue) | Self::Hwb(hue) | Self::Lch(hue) | Self::Oklch(hue) => Some(hue),
70 _ => None,
71 }
72 }
73
74 pub fn channels(self, color: Color) -> [Channel; 4] {
78 let mut channels: [Channel; 4] = match self {
79 Self::Srgb => Srgb::from(color).into(),
80 Self::SrgbLinear => LinearRgb::from(color).into(),
81 Self::DisplayP3 => DisplayP3::from(color).into(),
82 Self::A98Rgb => A98Rgb::from(color).into(),
83 Self::ProphotoRgb => ProphotoRgb::from(color).into(),
84 Self::Rec2020 => Rec2020::from(color).into(),
85 Self::Lab => Lab::from(color).into(),
86 Self::Oklab => Oklab::from(color).into(),
87 Self::XyzD50 => XyzD50::from(color).into(),
88 Self::XyzD65 => XyzD65::from(color).into(),
89 Self::Hsl(_) => Hsl::from(color).into(),
90 Self::Hwb(_) => Hwb::from(color).into(),
91 Self::Lch(_) => Lch::from(color).into(),
92 Self::Oklch(_) => Oklch::from(color).into(),
93 };
94 if let Some(hue) = self.hue_index()
95 && self.hue_is_powerless(&channels)
96 {
97 channels[hue] = None;
98 }
99 channels
100 }
101
102 fn hue_is_powerless(self, channels: &[Channel; 4]) -> bool {
103 match self {
104 Self::Hsl(_) => channels[1].is_some_and(|s| s.abs() < 1e-4),
105 Self::Hwb(_) => matches!((channels[1], channels[2]), (Some(w), Some(b)) if w + b >= 100.0 - 1e-4),
106 Self::Lch(_) => channels[1].is_some_and(|c| c.abs() < 0.0015),
107 Self::Oklch(_) => channels[1].is_some_and(|c| c.abs() < 0.000004),
108 _ => false,
109 }
110 }
111
112 pub fn color(self, channels: [Channel; 4]) -> Color {
114 match self {
115 Self::Srgb => Srgb::from(channels).into(),
116 Self::SrgbLinear => LinearRgb::from(channels).into(),
117 Self::DisplayP3 => DisplayP3::from(channels).into(),
118 Self::A98Rgb => A98Rgb::from(channels).into(),
119 Self::ProphotoRgb => ProphotoRgb::from(channels).into(),
120 Self::Rec2020 => Rec2020::from(channels).into(),
121 Self::Lab => Lab::from(channels).into(),
122 Self::Oklab => Oklab::from(channels).into(),
123 Self::XyzD50 => XyzD50::from(channels).into(),
124 Self::XyzD65 => XyzD65::from(channels).into(),
125 Self::Hsl(_) => Hsl::from(channels).into(),
126 Self::Hwb(_) => Hwb::from(channels).into(),
127 Self::Lch(_) => Lch::from(channels).into(),
128 Self::Oklch(_) => Oklch::from(channels).into(),
129 }
130 }
131
132 pub fn endpoints(self, first: [Channel; 4], second: [Channel; 4]) -> [[Channel; 4]; 2] {
139 let (mut first, mut second) = (first, second);
140 for (a, b) in first.iter_mut().zip(&mut second) {
141 (*a, *b) = (a.or(*b), b.or(*a));
142 }
143 if let Some((index, hue)) = self.hue_index().zip(self.hue_interpolation())
144 && let (Some(from), Some(to)) = (first[index], second[index])
145 {
146 let from = from.rem_euclid(360.0);
147 first[index] = Some(from);
148 second[index] = Some(from + hue.delta(from, to));
149 }
150 [first, second]
151 }
152
153 pub fn mix(self, first: [Channel; 4], second: [Channel; 4], percentage: f64) -> [Channel; 4] {
159 let t = percentage / 100.0;
160 let lerp = |a: f64, b: f64| a * (1.0 - t) + b * t;
161 let [first, second] = self.endpoints(first, second);
162 let [a1, a2] = [first[3], second[3]].map(|alpha| alpha.unwrap_or(0.0) / 100.0);
163 let alpha = lerp(a1, a2);
164 let mut mixed = [None, None, None, first[3].map(|_| alpha * 100.0)];
165 for i in 0..3 {
166 mixed[i] = first[i].zip(second[i]).map(|(a, b)| {
167 if self.hue_index() == Some(i) {
168 lerp(a, b).rem_euclid(360.0)
169 } else if alpha == 0.0 {
170 lerp(a, b)
171 } else {
172 lerp(a * a1, b * a2) / alpha
173 }
174 });
175 }
176 mixed
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::{COLOR_EPSILON, ColorDistance, Named};
184
185 const SHORTER: HueInterpolation = HueInterpolation::Shorter;
186
187 fn halfway(space: InterpolationSpace, first: impl Into<Color>, second: impl Into<Color>) -> Color {
188 space.color(space.mix(space.channels(first.into()), space.channels(second.into()), 50.0))
189 }
190
191 #[test]
192 fn channels_round_trip_through_every_space() {
193 let color = Color::Srgb(Srgb::new(0.25, 0.5, 0.75, 50.0));
194 for space in [
195 InterpolationSpace::Srgb,
196 InterpolationSpace::SrgbLinear,
197 InterpolationSpace::DisplayP3,
198 InterpolationSpace::A98Rgb,
199 InterpolationSpace::ProphotoRgb,
200 InterpolationSpace::Rec2020,
201 InterpolationSpace::Lab,
202 InterpolationSpace::Oklab,
203 InterpolationSpace::XyzD50,
204 InterpolationSpace::XyzD65,
205 InterpolationSpace::Hsl(SHORTER),
206 InterpolationSpace::Hwb(SHORTER),
207 InterpolationSpace::Lch(SHORTER),
208 InterpolationSpace::Oklch(SHORTER),
209 ] {
210 let srgb = Srgb::from(space.color(space.channels(color)));
211 let [r, g, b, a] = [srgb.red, srgb.green, srgb.blue, srgb.alpha as f64];
212 for (got, want) in [r, g, b, a].into_iter().zip([0.25, 0.5, 0.75, 50.0]) {
213 assert!((got - want).abs() < 1e-3, "{space:?}: {srgb:?}");
214 }
215 }
216 }
217
218 #[test]
219 fn achromatic_hues_are_powerless() {
220 for space in [
221 InterpolationSpace::Hsl(SHORTER),
222 InterpolationSpace::Hwb(SHORTER),
223 InterpolationSpace::Lch(SHORTER),
224 InterpolationSpace::Oklch(SHORTER),
225 ] {
226 let channels = space.channels(Color::Named(Named::White));
227 assert_eq!(channels[space.hue_index().unwrap()], None, "{space:?}");
228 }
229 let red = InterpolationSpace::Oklch(SHORTER).channels(Color::Named(Named::Red))[2];
230 assert!(red.is_some_and(|hue| (hue - 29.2339).abs() < 1e-3), "color.js: red is oklch(0.62796 0.25768 29.2339)");
231 }
232
233 #[test]
234 fn longer_hue_keeps_a_half_turn_in_its_direction() {
235 let hue = |from: f64, to: f64| {
236 let color = |hue| [Some(hue), Some(50.0), Some(50.0), Some(100.0)];
237 InterpolationSpace::Hsl(HueInterpolation::Longer).mix(color(from), color(to), 50.0)[0]
238 };
239 for (from, to, want) in [(0.0, 180.0, 90.0), (180.0, 0.0, 90.0), (0.0, 90.0, 225.0), (90.0, 0.0, 225.0)] {
240 assert_eq!(hue(from, to), Some(want), "css-color-4 longer hue from {from} to {to}");
241 }
242 }
243
244 #[test]
245 fn hues_travel_in_the_direction_of_the_space() {
246 let hsl = |hue| Hsl::from(halfway(InterpolationSpace::Hsl(hue), Named::Rebeccapurple, Named::Hotpink));
247 for (hue, want) in [
248 (HueInterpolation::Shorter, 300.0),
249 (HueInterpolation::Longer, 120.0),
250 (HueInterpolation::Decreasing, 120.0),
251 (HueInterpolation::Increasing, 300.0),
252 ] {
253 let mixed = hsl(hue);
254 let source = "css-color-4: rebeccapurple is hsl(270 50% 40%), hotpink hsl(330 100% 70.588%)";
255 let want = Hsl::new(want, 75.0, 55.294117, 100.0);
256 assert!(mixed.close_to(want, COLOR_EPSILON), "{source}, so {hue:?} gives {want:?}, not {mixed:?}");
257 }
258 let [red, blue] = [0.0, 240.0].map(|hue| Hwb::new(hue, 0.0, 0.0, 100.0));
259 let mixed = Hwb::from(halfway(InterpolationSpace::Hwb(HueInterpolation::Longer), red, blue));
260 let want = Hwb::new(120.0, 0.0, 0.0, 100.0);
261 assert!(
262 mixed.close_to(want, COLOR_EPSILON),
263 "css-color-4 longer hue from 0 to 240 gives {want:?}, not {mixed:?}"
264 );
265 }
266
267 #[test]
268 fn missing_channels_take_the_other_colours() {
269 let first = [None, None, Some(0.0), Some(100.0)];
270 let second = [Some(200.0 / 255.0), None, Some(0.0), Some(100.0)];
271 let mixed = InterpolationSpace::Srgb.mix(first, second, 50.0);
272 assert_eq!(mixed, [Some(200.0 / 255.0), None, Some(0.0), Some(100.0)], "css-color-4 missing components");
273 }
274}