Skip to main content

chromashift/
mix.rs

1use crate::{
2	A98Rgb, Channel, DisplayP3, Hsl, Hwb, Lab, Lch, LinearRgb, Oklab, Oklch, PolarLayout, ProphotoRgb, Rec2020, Srgb,
3	XyzD50, XyzD65,
4};
5
6/// A direction to interopolate hue values between, when mixing colours.
7#[derive(Debug, Clone, Copy, PartialEq, Default)]
8pub enum HueInterpolation {
9	#[default]
10	Shorter,
11	Longer,
12	Increasing,
13	Decreasing,
14}
15
16/// Trait for calculating mixing two colors together.
17///
18/// This trait provides a static method which will receive two colours, and can output a Self which should be the result
19/// of both colours mixed by the given percentage (the percentage pertains to how much the second colour should apply to
20/// the first).
21///
22/// Per CSS Color (4 12.3 & 5 3.5), interpolation uses premultiplied alpha:
23/// 1. Premultiply each component by its alpha
24/// 2. Linearly interpolate premultiplied values and alpha independently
25/// 3. Un-premultiply by dividing by the interpolated alpha
26pub trait ColorMix<T, U>: Sized
27where
28	T: Into<Self>,
29	U: Into<Self>,
30{
31	fn mix(first: T, second: U, percentage: f64) -> Self;
32}
33
34/// Trait for calculating mixing two colors together, with a hue direction for Polar colour spaces.
35///
36/// This trait provides a static method which will receive two colours, and can output a Self which should be the result
37/// of both colours mixed by the given percentage (the percentage pertains to how much the second colour should apply to
38/// the first). The Hue direction should be respected. If the colour space is not Polar then consider [ColorMix]
39/// instead.
40///
41/// Per CSS Color 4 12.3, premultiplied alpha is used for non-hue components. The hue component
42/// is NOT premultiplied - it is interpolated directly using the specified hue interpolation method.
43pub trait ColorMixPolar<T, U>: Sized
44where
45	T: Into<Self>,
46	U: Into<Self>,
47{
48	fn mix_polar(first: T, second: U, percentage: f64, hue_interpolation: HueInterpolation) -> Self;
49}
50
51/// Interpolate a single component using premultiplied alpha.
52///
53/// CSS Color 4 12.3:
54///   premultiplied1 = component1 * alpha1
55///   premultiplied2 = component2 * alpha2
56///   result_premultiplied = premultiplied1 * (1 - t) + premultiplied2 * t
57///   result = result_premultiplied / result_alpha
58fn premultiply_lerp(c1: f64, a1: f64, c2: f64, a2: f64, t: f64, result_alpha: f64) -> f64 {
59	if result_alpha == 0.0 {
60		return c1 * (1.0 - t) + c2 * t;
61	}
62	let pm1 = c1 * a1;
63	let pm2 = c2 * a2;
64	(pm1 * (1.0 - t) + pm2 * t) / result_alpha
65}
66
67/// Given two hues (`h1`, `h2`), a percentage transform (`t`), and an interpolation direction, return a new Hue rotation
68/// transformed by that amount.
69pub fn interpolate_hue(h1: f64, h2: f64, t: f64, interpolation: HueInterpolation) -> f64 {
70	let (h1, h2) = (h1.rem_euclid(360.0), h2.rem_euclid(360.0));
71
72	let diff = match interpolation {
73		HueInterpolation::Shorter => {
74			let d = h2 - h1;
75			if d.abs() <= 180.0 {
76				d
77			} else if d > 180.0 {
78				d - 360.0
79			} else {
80				d + 360.0
81			}
82		}
83		HueInterpolation::Longer => {
84			let d = h2 - h1;
85			if d.abs() > 180.0 {
86				d
87			} else if d > 0.0 {
88				d - 360.0
89			} else {
90				d + 360.0
91			}
92		}
93		HueInterpolation::Increasing => {
94			let mut d = h2 - h1;
95			if d < 0.0 {
96				d += 360.0;
97			}
98			d
99		}
100		HueInterpolation::Decreasing => {
101			let mut d = h2 - h1;
102			if d > 0.0 {
103				d -= 360.0;
104			}
105			d
106		}
107	};
108
109	(h1 + diff * t).rem_euclid(360.0)
110}
111
112/// Mixes two colours channel-by-channel, honouring `none` channels by adopting the analogous channel from the other
113/// colour. When both are missing the result resolves to 0.
114///
115/// Non-hue colour components and alpha use premultiplied alpha interpolation. Polar colours use `hue_index` to select
116/// which (if any) component is a hue: that channel uses hue interpolation with the given direction.
117pub fn mix_channels(
118	first: [Channel; 4],
119	second: [Channel; 4],
120	percentage: f64,
121	hue_index: Option<usize>,
122	hue_interpolation: HueInterpolation,
123) -> [Channel; 4] {
124	let t = percentage / 100.0;
125	let mut out = [Channel::default(); 4];
126
127	// Alpha: resolve none, then lerp. Store in 0..100 range.
128	let (alpha_a, alpha_b) = match (first[3], second[3]) {
129		(None, None) => (0.0, 0.0),
130		(None, Some(b)) => (b, b),
131		(Some(a), None) => (a, a),
132		(Some(a), Some(b)) => (a, b),
133	};
134	let a1 = alpha_a / 100.0;
135	let a2 = alpha_b / 100.0;
136	let alpha = a1 * (1.0 - t) + a2 * t;
137	out[3] = if first[3].or(second[3]).is_none() { None } else { Some(alpha * 100.0) };
138
139	for i in 0..3 {
140		let (l, r) = (first[i], second[i]);
141		let (a, b) = match (l, r) {
142			(None, None) => (None, None),
143			(None, Some(b)) => (Some(b), Some(b)),
144			(Some(a), None) => (Some(a), Some(a)),
145			(Some(a), Some(b)) => (Some(a), Some(b)),
146		};
147		out[i] = match (a, b) {
148			(None, _) | (_, None) => None,
149			(Some(av), Some(bv)) => Some(if hue_index == Some(i) {
150				interpolate_hue(av, bv, t, hue_interpolation)
151			} else {
152				premultiply_lerp(av, a1, bv, a2, t, alpha)
153			}),
154		};
155	}
156
157	out
158}
159
160/// Mixes two values of the same colour type via [`mix_channels`]. Devolves types to [Channel; 4] before mixing.
161fn mix_in<C, T, U>(first: T, second: U, percentage: f64, hue_interpolation: HueInterpolation) -> C
162where
163	C: From<T> + From<U> + From<[Channel; 4]> + Into<[Channel; 4]> + PolarLayout,
164{
165	let first: [Channel; 4] = C::from(first).into();
166	let second: [Channel; 4] = C::from(second).into();
167	let out = mix_channels(first, second, percentage, C::HUE_INDEX, hue_interpolation);
168	C::from(out)
169}
170
171mod sealed {
172	pub trait PolarColor {}
173}
174
175impl sealed::PolarColor for Hsl {}
176impl sealed::PolarColor for Hwb {}
177impl sealed::PolarColor for Lch {}
178impl sealed::PolarColor for Oklch {}
179
180impl<T, U, V> ColorMix<T, U> for V
181where
182	V: ColorMixPolar<T, U> + sealed::PolarColor + Sized,
183	T: Into<V>,
184	U: Into<V>,
185{
186	fn mix(first: T, second: U, percentage: f64) -> V {
187		ColorMixPolar::mix_polar(first, second, percentage, HueInterpolation::Shorter)
188	}
189}
190
191/// Implements [`ColorMix`] for a rectangular space by delegating to [`mix_in`].
192macro_rules! impl_color_mix {
193	($ty:ty) => {
194		impl<T, U> ColorMix<T, U> for $ty
195		where
196			Self: From<T> + From<U>,
197		{
198			fn mix(first: T, second: U, percentage: f64) -> Self {
199				mix_in::<Self, T, U>(first, second, percentage, HueInterpolation::Shorter)
200			}
201		}
202	};
203}
204
205/// Implements [`ColorMixPolar`] for a polar space by delegating to [`mix_in`].
206macro_rules! impl_color_mix_polar {
207	($ty:ty) => {
208		impl<T, U> ColorMixPolar<T, U> for $ty
209		where
210			Self: From<T> + From<U>,
211		{
212			fn mix_polar(first: T, second: U, percentage: f64, hue_interpolation: HueInterpolation) -> Self {
213				mix_in::<Self, T, U>(first, second, percentage, hue_interpolation)
214			}
215		}
216	};
217}
218
219impl_color_mix!(Srgb);
220impl_color_mix!(LinearRgb);
221impl_color_mix!(A98Rgb);
222impl_color_mix!(DisplayP3);
223impl_color_mix!(ProphotoRgb);
224impl_color_mix!(Rec2020);
225impl_color_mix!(Lab);
226impl_color_mix!(Oklab);
227impl_color_mix!(XyzD50);
228impl_color_mix!(XyzD65);
229impl_color_mix_polar!(Hsl);
230impl_color_mix_polar!(Hwb);
231impl_color_mix_polar!(Lch);
232impl_color_mix_polar!(Oklch);
233
234#[cfg(test)]
235mod tests {
236	use super::*;
237	use crate::*;
238
239	macro_rules! assert_close_to {
240		($a: expr, $b: expr) => {
241			assert!($a.close_to($b, COLOR_EPSILON), "Expected {:?} to be (closely) equal to {:?}", $a, $b);
242		};
243	}
244
245	#[test]
246	fn test_basic_mix() {
247		let red = Srgb::new(255, 0, 0, 100.0);
248		let blue = Srgb::new(0, 0, 255, 100.0);
249		assert_close_to!(Srgb::mix(red, blue, 50.0), Srgb::new(128, 0, 128, 100.0));
250	}
251
252	#[test]
253	fn test_mix_named_in_oklab() {
254		assert_close_to!(
255			Oklch::mix(Named::Rebeccapurple, Named::Hotpink, 50.0),
256			Oklch::new(0.5842845967725198, 0.17868573405015944, 327.6838446374328, 100.0)
257		);
258	}
259
260	#[test]
261	fn test_mix_named_in_hsl_polar() {
262		assert_close_to!(Hsl::mix(Named::Rebeccapurple, Named::Hotpink, 50.0), Hsl::new(300.0, 75.0, 55.294117, 100.0));
263		assert_close_to!(
264			Hsl::mix_polar(Named::Rebeccapurple, Named::Hotpink, 50.0, HueInterpolation::Longer),
265			Hsl::new(120.0, 75.0, 55.294117, 100.0)
266		);
267		assert_close_to!(
268			Hsl::mix_polar(Named::Rebeccapurple, Named::Hotpink, 50.0, HueInterpolation::Decreasing),
269			Hsl::new(120.0, 75.0, 55.294117, 100.0)
270		);
271		assert_close_to!(
272			Hsl::mix_polar(Named::Rebeccapurple, Named::Hotpink, 50.0, HueInterpolation::Increasing),
273			Hsl::new(300.0, 75.0, 55.294117, 100.0)
274		);
275	}
276
277	#[test]
278	fn test_alpha_mixing() {
279		let color1 = Srgb::new(255, 0, 0, 80.0);
280		let color2 = Srgb::new(0, 0, 255, 40.0);
281
282		let mixed = Srgb::mix(color1, color2, 50.0);
283		assert_eq!(mixed.red, 170);
284		assert_eq!(mixed.green, 0);
285		assert_eq!(mixed.blue, 85);
286		assert_eq!(mixed.alpha, 60.0);
287	}
288
289	#[test]
290	fn test_hwb_mix() {
291		// Hwb is polar - default mix uses Shorter hue interpolation
292		// 0° to 240°: diff=240 > 180, so shorter wraps via 360°, midpoint is 300°
293		let red = Hwb::new(0.0, 0.0, 0.0, 100.0);
294		let blue = Hwb::new(240.0, 0.0, 0.0, 100.0);
295		let mixed = Hwb::mix(red, blue, 50.0);
296		assert_close_to!(mixed, Hwb::new(300.0, 0.0, 0.0, 100.0));
297	}
298
299	#[test]
300	fn test_hwb_mix_polar() {
301		// 0° to 240°: longer arc goes through 120°
302		let red = Hwb::new(0.0, 0.0, 0.0, 100.0);
303		let blue = Hwb::new(240.0, 0.0, 0.0, 100.0);
304		assert_close_to!(Hwb::mix_polar(red, blue, 50.0, HueInterpolation::Longer), Hwb::new(120.0, 0.0, 0.0, 100.0));
305	}
306
307	#[test]
308	fn test_a98_rgb_mix() {
309		let c1 = A98Rgb::new(1.0, 0.0, 0.0, 100.0);
310		let c2 = A98Rgb::new(0.0, 0.0, 1.0, 100.0);
311		let mixed = A98Rgb::mix(c1, c2, 50.0);
312		assert_close_to!(mixed, A98Rgb::new(0.5, 0.0, 0.5, 100.0));
313	}
314
315	/// WPT: color-mix(in lab, lab(10 20 30 / .4), lab(50 60 70 / .8)) → lab(36.666664 46.666664 56.666664 / 0.6)
316	#[test]
317	fn test_premultiplied_alpha_lab() {
318		let c1 = Lab::new(10.0, 20.0, 30.0, 40.0);
319		let c2 = Lab::new(50.0, 60.0, 70.0, 80.0);
320		let mixed = Lab::mix(c1, c2, 50.0);
321		assert_close_to!(mixed, Lab::new(36.666664, 46.666664, 56.666664, 60.0));
322	}
323
324	/// WPT: color-mix(in lab, lab(10 20 30 / .4) 25%, lab(50 60 70 / .8)) → lab(44.285713 54.285717 64.28571 / 0.7)
325	#[test]
326	fn test_premultiplied_alpha_lab_25_75() {
327		let c1 = Lab::new(10.0, 20.0, 30.0, 40.0);
328		let c2 = Lab::new(50.0, 60.0, 70.0, 80.0);
329		// 25% first, 75% second: mix_percentage = 75
330		let mixed = Lab::mix(c1, c2, 75.0);
331		assert_close_to!(mixed, Lab::new(44.285713, 54.285717, 64.28571, 70.0));
332	}
333
334	/// WPT: color-mix(in oklch, oklch(0.1 0.2 30deg / .4), oklch(0.5 0.6 70deg / .8)) → oklch(0.36666664 0.46666664 50 / 0.6)
335	#[test]
336	fn test_premultiplied_alpha_oklch() {
337		let c1 = Oklch::new(0.1, 0.2, 30.0, 40.0);
338		let c2 = Oklch::new(0.5, 0.6, 70.0, 80.0);
339		let mixed = Oklch::mix(c1, c2, 50.0);
340		assert_close_to!(mixed, Oklch::new(0.36666664, 0.46666664, 50.0, 60.0));
341	}
342
343	/// When both alphas are 100%, premultiplied interpolation == simple interpolation.
344	#[test]
345	fn test_premultiplied_alpha_opaque_same_as_simple() {
346		let c1 = Lab::new(10.0, 20.0, 30.0, 100.0);
347		let c2 = Lab::new(50.0, 60.0, 70.0, 100.0);
348		let mixed = Lab::mix(c1, c2, 50.0);
349		assert_close_to!(mixed, Lab::new(30.0, 40.0, 50.0, 100.0));
350	}
351
352	/// `None` channel in one input adopts the analogous channel from the other.
353	#[test]
354	fn test_none_channel_substitution() {
355		let first = [None, Some(0.0), Some(0.0), Some(100.0)];
356		let second: [Channel; 4] = Srgb::new(200, 0, 0, 100.0).into();
357		let out = mix_channels(first, second, 50.0, Srgb::HUE_INDEX, HueInterpolation::Shorter);
358		let mixed: Srgb = out.into();
359		assert_eq!(mixed, Srgb::new(200, 0, 0, 100.0));
360	}
361}