Skip to main content

chromashift/
interpolation_space.rs

1use crate::{
2	A98Rgb, Channel, Color, DisplayP3, Hsl, Hwb, Lab, Lch, LinearRgb, Oklab, Oklch, ProphotoRgb, Rec2020, Srgb, XyzD50,
3	XyzD65,
4};
5
6/// A direction hues travel in between two colours, when interpolating in a polar space.
7///
8/// <https://drafts.csswg.org/css-color-4/#hue-interpolation>
9#[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	/// The signed number of degrees from hue `from` to hue `to` when interpolating in this direction.
20	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/// A colour space colours can be interpolated in, with the direction hues travel in for polar spaces, as named by CSS
35/// `<color-interpolation-method>`.
36///
37/// <https://drafts.csswg.org/css-color-4/#interpolation-space>
38#[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	/// The index of the hue among the first three channels, for polar spaces.
58	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	/// The direction hues travel in, for polar spaces.
67	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	/// The channels of `color` in this space, in the units of this space's colour type, with a powerless hue missing.
75	///
76	/// <https://drafts.csswg.org/css-color-4/#powerless>
77	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	/// The colour with `channels` in this space, in the units of this space's colour type. Missing channels are 0.
113	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	/// `first` and `second` ready to interpolate straight between, channel by channel: a channel missing from one takes
133	/// the other's value, and stays missing when both miss it. In polar spaces, the first's hue is put within `0..360`
134	/// and the second's turned by whole turns, so that going straight from one to the other travels this space's
135	/// [`hue_interpolation`](Self::hue_interpolation).
136	///
137	/// <https://drafts.csswg.org/css-color-4/#interpolation-missing>
138	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	/// The channels `percentage` of the way from `first` to `second`, interpolated straight between their
154	/// [`endpoints`](Self::endpoints), with every channel but the hue premultiplied by alpha. The hue ends within
155	/// `0..360`.
156	///
157	/// <https://drafts.csswg.org/css-color-4/#interpolation-alpha>
158	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}