chromashift/
mix.rs

1use crate::{
2	A98Rgb, DisplayP3, Hsl, Hwb, Lab, Lch, LinearRgb, Oklab, Oklch, ProphotoRgb, Rec2020, Srgb, XyzD50, XyzD65,
3};
4
5/// A direction to interopolate hue values between, when mixing colours.
6#[derive(Debug, Clone, Copy, PartialEq, Default)]
7pub enum HueInterpolation {
8	#[default]
9	Shorter,
10	Longer,
11	Increasing,
12	Decreasing,
13}
14
15/// Trait for calculating mixing two colors together.
16///
17/// This trait provides a static method which will receive two colours, and can output a Self which should be the result
18/// of both colours mixed by the given percentage (the percentage pertains to how much the second colour should apply to
19/// the first).
20///
21/// Per CSS Color (4 12.3 & 5 3.5), interpolation uses premultiplied alpha:
22/// 1. Premultiply each component by its alpha
23/// 2. Linearly interpolate premultiplied values and alpha independently
24/// 3. Un-premultiply by dividing by the interpolated alpha
25pub trait ColorMix<T, U>: Sized
26where
27	T: Into<Self>,
28	U: Into<Self>,
29{
30	fn mix(first: T, second: U, percentage: f64) -> Self;
31}
32
33/// Trait for calculating mixing two colors together, with a hue direction for Polar colour spaces.
34///
35/// This trait provides a static method which will receive two colours, and can output a Self which should be the result
36/// of both colours mixed by the given percentage (the percentage pertains to how much the second colour should apply to
37/// the first). The Hue direction should be respected. If the colour space is not Polar then consider [ColorMix]
38/// instead.
39///
40/// Per CSS Color 4 12.3, premultiplied alpha is used for non-hue components. The hue component
41/// is NOT premultiplied - it is interpolated directly using the specified hue interpolation method.
42pub trait ColorMixPolar<T, U>: Sized
43where
44	T: Into<Self>,
45	U: Into<Self>,
46{
47	fn mix_polar(first: T, second: U, percentage: f64, hue_interpolation: HueInterpolation) -> Self;
48}
49
50/// Interpolate a single component using premultiplied alpha.
51///
52/// CSS Color 4 12.3:
53///   premultiplied1 = component1 * alpha1
54///   premultiplied2 = component2 * alpha2
55///   result_premultiplied = premultiplied1 * (1 - t) + premultiplied2 * t
56///   result = result_premultiplied / result_alpha
57fn premultiply_lerp(c1: f64, a1: f64, c2: f64, a2: f64, t: f64, result_alpha: f64) -> f64 {
58	if result_alpha == 0.0 {
59		return c1 * (1.0 - t) + c2 * t;
60	}
61	let pm1 = c1 * a1;
62	let pm2 = c2 * a2;
63	(pm1 * (1.0 - t) + pm2 * t) / result_alpha
64}
65
66/// Given two hues (`h1`, `h2`), a percentage transform (`t`), and an interpolation direction, return a new Hue rotation
67/// transformed by that amount.
68pub fn interpolate_hue(h1: f64, h2: f64, t: f64, interpolation: HueInterpolation) -> f64 {
69	let (h1, h2) = (h1.rem_euclid(360.0), h2.rem_euclid(360.0));
70
71	let diff = match interpolation {
72		HueInterpolation::Shorter => {
73			let d = h2 - h1;
74			if d.abs() <= 180.0 {
75				d
76			} else if d > 180.0 {
77				d - 360.0
78			} else {
79				d + 360.0
80			}
81		}
82		HueInterpolation::Longer => {
83			let d = h2 - h1;
84			if d.abs() > 180.0 {
85				d
86			} else if d > 0.0 {
87				d - 360.0
88			} else {
89				d + 360.0
90			}
91		}
92		HueInterpolation::Increasing => {
93			let mut d = h2 - h1;
94			if d < 0.0 {
95				d += 360.0;
96			}
97			d
98		}
99		HueInterpolation::Decreasing => {
100			let mut d = h2 - h1;
101			if d > 0.0 {
102				d -= 360.0;
103			}
104			d
105		}
106	};
107
108	(h1 + diff * t).rem_euclid(360.0)
109}
110
111mod sealed {
112	pub trait PolarColor {}
113}
114
115impl sealed::PolarColor for Hsl {}
116impl sealed::PolarColor for Hwb {}
117impl sealed::PolarColor for Lch {}
118impl sealed::PolarColor for Oklch {}
119
120impl<T, U, V> ColorMix<T, U> for V
121where
122	V: ColorMixPolar<T, U> + sealed::PolarColor + Sized,
123	T: Into<V>,
124	U: Into<V>,
125{
126	fn mix(first: T, second: U, percentage: f64) -> V {
127		ColorMixPolar::mix_polar(first, second, percentage, HueInterpolation::Shorter)
128	}
129}
130
131impl<T, U> ColorMix<T, U> for Srgb
132where
133	Self: From<T> + From<U>,
134{
135	fn mix(first: T, second: U, percentage: f64) -> Self {
136		let first: Self = first.into();
137		let second: Self = second.into();
138		let t = percentage / 100.0;
139		let a1 = first.alpha as f64 / 100.0;
140		let a2 = second.alpha as f64 / 100.0;
141		let a = a1 * (1.0 - t) + a2 * t;
142		let r = premultiply_lerp(first.red as f64, a1, second.red as f64, a2, t, a);
143		let g = premultiply_lerp(first.green as f64, a1, second.green as f64, a2, t, a);
144		let b = premultiply_lerp(first.blue as f64, a1, second.blue as f64, a2, t, a);
145		Srgb::new(r.round() as u8, g.round() as u8, b.round() as u8, (a * 100.0) as f32)
146	}
147}
148
149impl<T, U> ColorMix<T, U> for LinearRgb
150where
151	Self: From<T> + From<U>,
152{
153	fn mix(first: T, second: U, percentage: f64) -> Self {
154		let first: Self = first.into();
155		let second: Self = second.into();
156		let t = percentage / 100.0;
157		let a1 = first.alpha as f64 / 100.0;
158		let a2 = second.alpha as f64 / 100.0;
159		let a = a1 * (1.0 - t) + a2 * t;
160		let r = premultiply_lerp(first.red, a1, second.red, a2, t, a);
161		let g = premultiply_lerp(first.green, a1, second.green, a2, t, a);
162		let b = premultiply_lerp(first.blue, a1, second.blue, a2, t, a);
163		LinearRgb::new(r, g, b, (a * 100.0) as f32)
164	}
165}
166
167impl<T, U> ColorMixPolar<T, U> for Hsl
168where
169	Self: From<T> + From<U>,
170{
171	fn mix_polar(first: T, second: U, percentage: f64, hue_interpolation: HueInterpolation) -> Self {
172		let first: Self = first.into();
173		let second: Self = second.into();
174		let t = percentage / 100.0;
175		let a1 = first.alpha as f64 / 100.0;
176		let a2 = second.alpha as f64 / 100.0;
177		let a = a1 * (1.0 - t) + a2 * t;
178		let h = interpolate_hue(first.hue as f64, second.hue as f64, t, hue_interpolation);
179		let s = premultiply_lerp(first.saturation as f64, a1, second.saturation as f64, a2, t, a);
180		let l = premultiply_lerp(first.lightness as f64, a1, second.lightness as f64, a2, t, a);
181		Hsl::new(h as f32, s as f32, l as f32, (a * 100.0) as f32)
182	}
183}
184
185impl<T, U> ColorMixPolar<T, U> for Hwb
186where
187	Self: From<T> + From<U>,
188{
189	fn mix_polar(first: T, second: U, percentage: f64, hue_interpolation: HueInterpolation) -> Self {
190		let first: Self = first.into();
191		let second: Self = second.into();
192		let t = percentage / 100.0;
193		let a1 = first.alpha as f64 / 100.0;
194		let a2 = second.alpha as f64 / 100.0;
195		let a = a1 * (1.0 - t) + a2 * t;
196		let h = interpolate_hue(first.hue as f64, second.hue as f64, t, hue_interpolation);
197		let w = premultiply_lerp(first.whiteness as f64, a1, second.whiteness as f64, a2, t, a);
198		let b = premultiply_lerp(first.blackness as f64, a1, second.blackness as f64, a2, t, a);
199		Hwb::new(h as f32, w as f32, b as f32, (a * 100.0) as f32)
200	}
201}
202
203impl<T, U> ColorMix<T, U> for A98Rgb
204where
205	Self: From<T> + From<U>,
206{
207	fn mix(first: T, second: U, percentage: f64) -> Self {
208		let first: Self = first.into();
209		let second: Self = second.into();
210		let t = percentage / 100.0;
211		let a1 = first.alpha as f64 / 100.0;
212		let a2 = second.alpha as f64 / 100.0;
213		let a = a1 * (1.0 - t) + a2 * t;
214		let r = premultiply_lerp(first.red, a1, second.red, a2, t, a);
215		let g = premultiply_lerp(first.green, a1, second.green, a2, t, a);
216		let b = premultiply_lerp(first.blue, a1, second.blue, a2, t, a);
217		A98Rgb::new(r, g, b, (a * 100.0) as f32)
218	}
219}
220
221impl<T, U> ColorMix<T, U> for DisplayP3
222where
223	Self: From<T> + From<U>,
224{
225	fn mix(first: T, second: U, percentage: f64) -> Self {
226		let first: Self = first.into();
227		let second: Self = second.into();
228		let t = percentage / 100.0;
229		let a1 = first.alpha as f64 / 100.0;
230		let a2 = second.alpha as f64 / 100.0;
231		let a = a1 * (1.0 - t) + a2 * t;
232		let r = premultiply_lerp(first.red, a1, second.red, a2, t, a);
233		let g = premultiply_lerp(first.green, a1, second.green, a2, t, a);
234		let b = premultiply_lerp(first.blue, a1, second.blue, a2, t, a);
235		DisplayP3::new(r, g, b, (a * 100.0) as f32)
236	}
237}
238
239impl<T, U> ColorMix<T, U> for ProphotoRgb
240where
241	Self: From<T> + From<U>,
242{
243	fn mix(first: T, second: U, percentage: f64) -> Self {
244		let first: Self = first.into();
245		let second: Self = second.into();
246		let t = percentage / 100.0;
247		let a1 = first.alpha as f64 / 100.0;
248		let a2 = second.alpha as f64 / 100.0;
249		let a = a1 * (1.0 - t) + a2 * t;
250		let r = premultiply_lerp(first.red, a1, second.red, a2, t, a);
251		let g = premultiply_lerp(first.green, a1, second.green, a2, t, a);
252		let b = premultiply_lerp(first.blue, a1, second.blue, a2, t, a);
253		ProphotoRgb::new(r, g, b, (a * 100.0) as f32)
254	}
255}
256
257impl<T, U> ColorMix<T, U> for Rec2020
258where
259	Self: From<T> + From<U>,
260{
261	fn mix(first: T, second: U, percentage: f64) -> Self {
262		let first: Self = first.into();
263		let second: Self = second.into();
264		let t = percentage / 100.0;
265		let a1 = first.alpha as f64 / 100.0;
266		let a2 = second.alpha as f64 / 100.0;
267		let a = a1 * (1.0 - t) + a2 * t;
268		let r = premultiply_lerp(first.red, a1, second.red, a2, t, a);
269		let g = premultiply_lerp(first.green, a1, second.green, a2, t, a);
270		let b = premultiply_lerp(first.blue, a1, second.blue, a2, t, a);
271		Rec2020::new(r, g, b, (a * 100.0) as f32)
272	}
273}
274
275impl<T, U> ColorMix<T, U> for Lab
276where
277	Self: From<T> + From<U>,
278{
279	fn mix(first: T, second: U, percentage: f64) -> Self {
280		let first: Self = first.into();
281		let second: Self = second.into();
282		let t = percentage / 100.0;
283		let a1 = first.alpha as f64 / 100.0;
284		let a2 = second.alpha as f64 / 100.0;
285		let a = a1 * (1.0 - t) + a2 * t;
286		let l = premultiply_lerp(first.lightness, a1, second.lightness, a2, t, a);
287		let ab_a = premultiply_lerp(first.a, a1, second.a, a2, t, a);
288		let ab_b = premultiply_lerp(first.b, a1, second.b, a2, t, a);
289		Lab::new(l, ab_a, ab_b, (a * 100.0) as f32)
290	}
291}
292
293impl<T, U> ColorMixPolar<T, U> for Lch
294where
295	Self: From<T> + From<U>,
296{
297	fn mix_polar(first: T, second: U, percentage: f64, hue_interpolation: HueInterpolation) -> Self {
298		let first: Self = first.into();
299		let second: Self = second.into();
300		let t = percentage / 100.0;
301		let a1 = first.alpha as f64 / 100.0;
302		let a2 = second.alpha as f64 / 100.0;
303		let a = a1 * (1.0 - t) + a2 * t;
304		let l = premultiply_lerp(first.lightness, a1, second.lightness, a2, t, a);
305		let c = premultiply_lerp(first.chroma, a1, second.chroma, a2, t, a);
306		let h = interpolate_hue(first.hue, second.hue, t, hue_interpolation);
307		Lch::new(l, c, h, (a * 100.0) as f32)
308	}
309}
310
311impl<T, U> ColorMix<T, U> for Oklab
312where
313	Self: From<T> + From<U>,
314{
315	fn mix(first: T, second: U, percentage: f64) -> Self {
316		let first: Self = first.into();
317		let second: Self = second.into();
318		let t = percentage / 100.0;
319		let a1 = first.alpha as f64 / 100.0;
320		let a2 = second.alpha as f64 / 100.0;
321		let a = a1 * (1.0 - t) + a2 * t;
322		let l = premultiply_lerp(first.lightness, a1, second.lightness, a2, t, a);
323		let ab_a = premultiply_lerp(first.a, a1, second.a, a2, t, a);
324		let ab_b = premultiply_lerp(first.b, a1, second.b, a2, t, a);
325		Oklab::new(l, ab_a, ab_b, (a * 100.0) as f32)
326	}
327}
328
329impl<T, U> ColorMixPolar<T, U> for Oklch
330where
331	Self: From<T> + From<U>,
332{
333	fn mix_polar(first: T, second: U, percentage: f64, hue_interpolation: HueInterpolation) -> Self {
334		let first: Self = first.into();
335		let second: Self = second.into();
336		let t = percentage / 100.0;
337		let a1 = first.alpha as f64 / 100.0;
338		let a2 = second.alpha as f64 / 100.0;
339		let a = a1 * (1.0 - t) + a2 * t;
340		let l = premultiply_lerp(first.lightness, a1, second.lightness, a2, t, a);
341		let c = premultiply_lerp(first.chroma, a1, second.chroma, a2, t, a);
342		let h = interpolate_hue(first.hue, second.hue, t, hue_interpolation);
343		Oklch::new(l, c, h, (a * 100.0) as f32)
344	}
345}
346
347impl<T, U> ColorMix<T, U> for XyzD50
348where
349	Self: From<T> + From<U>,
350{
351	fn mix(first: T, second: U, percentage: f64) -> Self {
352		let first: Self = first.into();
353		let second: Self = second.into();
354		let t = percentage / 100.0;
355		let a1 = first.alpha as f64 / 100.0;
356		let a2 = second.alpha as f64 / 100.0;
357		let a = a1 * (1.0 - t) + a2 * t;
358		let x = premultiply_lerp(first.x, a1, second.x, a2, t, a);
359		let y = premultiply_lerp(first.y, a1, second.y, a2, t, a);
360		let z = premultiply_lerp(first.z, a1, second.z, a2, t, a);
361		XyzD50::new(x, y, z, (a * 100.0) as f32)
362	}
363}
364
365impl<T, U> ColorMix<T, U> for XyzD65
366where
367	Self: From<T> + From<U>,
368{
369	fn mix(first: T, second: U, percentage: f64) -> Self {
370		let first: Self = first.into();
371		let second: Self = second.into();
372		let t = percentage / 100.0;
373		let a1 = first.alpha as f64 / 100.0;
374		let a2 = second.alpha as f64 / 100.0;
375		let a = a1 * (1.0 - t) + a2 * t;
376		let x = premultiply_lerp(first.x, a1, second.x, a2, t, a);
377		let y = premultiply_lerp(first.y, a1, second.y, a2, t, a);
378		let z = premultiply_lerp(first.z, a1, second.z, a2, t, a);
379		XyzD65::new(x, y, z, (a * 100.0) as f32)
380	}
381}
382
383#[cfg(test)]
384mod tests {
385	use super::*;
386	use crate::*;
387
388	macro_rules! assert_close_to {
389		($a: expr, $b: expr) => {
390			assert!($a.close_to($b, COLOR_EPSILON), "Expected {:?} to be (closely) equal to {:?}", $a, $b);
391		};
392	}
393
394	#[test]
395	fn test_basic_mix() {
396		let red = Srgb::new(255, 0, 0, 100.0);
397		let blue = Srgb::new(0, 0, 255, 100.0);
398		assert_close_to!(Srgb::mix(red, blue, 50.0), Srgb::new(128, 0, 128, 100.0));
399	}
400
401	#[test]
402	fn test_mix_named_in_oklab() {
403		assert_close_to!(
404			Oklch::mix(Named::Rebeccapurple, Named::Hotpink, 50.0),
405			Oklch::new(0.5842845967725198, 0.17868573405015944, 327.6838446374328, 100.0)
406		);
407	}
408
409	#[test]
410	fn test_mix_named_in_hsl_polar() {
411		assert_close_to!(Hsl::mix(Named::Rebeccapurple, Named::Hotpink, 50.0), Hsl::new(300.0, 75.0, 55.294117, 100.0));
412		assert_close_to!(
413			Hsl::mix_polar(Named::Rebeccapurple, Named::Hotpink, 50.0, HueInterpolation::Longer),
414			Hsl::new(120.0, 75.0, 55.294117, 100.0)
415		);
416		assert_close_to!(
417			Hsl::mix_polar(Named::Rebeccapurple, Named::Hotpink, 50.0, HueInterpolation::Decreasing),
418			Hsl::new(120.0, 75.0, 55.294117, 100.0)
419		);
420		assert_close_to!(
421			Hsl::mix_polar(Named::Rebeccapurple, Named::Hotpink, 50.0, HueInterpolation::Increasing),
422			Hsl::new(300.0, 75.0, 55.294117, 100.0)
423		);
424	}
425
426	#[test]
427	fn test_alpha_mixing() {
428		let color1 = Srgb::new(255, 0, 0, 80.0);
429		let color2 = Srgb::new(0, 0, 255, 40.0);
430
431		let mixed = Srgb::mix(color1, color2, 50.0);
432		assert_eq!(mixed.red, 170);
433		assert_eq!(mixed.green, 0);
434		assert_eq!(mixed.blue, 85);
435		assert_eq!(mixed.alpha, 60.0);
436	}
437
438	#[test]
439	fn test_hwb_mix() {
440		// Hwb is polar - default mix uses Shorter hue interpolation
441		// 0° to 240°: diff=240 > 180, so shorter wraps via 360°, midpoint is 300°
442		let red = Hwb::new(0.0, 0.0, 0.0, 100.0);
443		let blue = Hwb::new(240.0, 0.0, 0.0, 100.0);
444		let mixed = Hwb::mix(red, blue, 50.0);
445		assert_close_to!(mixed, Hwb::new(300.0, 0.0, 0.0, 100.0));
446	}
447
448	#[test]
449	fn test_hwb_mix_polar() {
450		// 0° to 240°: longer arc goes through 120°
451		let red = Hwb::new(0.0, 0.0, 0.0, 100.0);
452		let blue = Hwb::new(240.0, 0.0, 0.0, 100.0);
453		assert_close_to!(Hwb::mix_polar(red, blue, 50.0, HueInterpolation::Longer), Hwb::new(120.0, 0.0, 0.0, 100.0));
454	}
455
456	#[test]
457	fn test_a98_rgb_mix() {
458		let c1 = A98Rgb::new(1.0, 0.0, 0.0, 100.0);
459		let c2 = A98Rgb::new(0.0, 0.0, 1.0, 100.0);
460		let mixed = A98Rgb::mix(c1, c2, 50.0);
461		assert_close_to!(mixed, A98Rgb::new(0.5, 0.0, 0.5, 100.0));
462	}
463
464	/// WPT: color-mix(in lab, lab(10 20 30 / .4), lab(50 60 70 / .8)) → lab(36.666664 46.666664 56.666664 / 0.6)
465	#[test]
466	fn test_premultiplied_alpha_lab() {
467		let c1 = Lab::new(10.0, 20.0, 30.0, 40.0);
468		let c2 = Lab::new(50.0, 60.0, 70.0, 80.0);
469		let mixed = Lab::mix(c1, c2, 50.0);
470		assert_close_to!(mixed, Lab::new(36.666664, 46.666664, 56.666664, 60.0));
471	}
472
473	/// 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)
474	#[test]
475	fn test_premultiplied_alpha_lab_25_75() {
476		let c1 = Lab::new(10.0, 20.0, 30.0, 40.0);
477		let c2 = Lab::new(50.0, 60.0, 70.0, 80.0);
478		// 25% first, 75% second: mix_percentage = 75
479		let mixed = Lab::mix(c1, c2, 75.0);
480		assert_close_to!(mixed, Lab::new(44.285713, 54.285717, 64.28571, 70.0));
481	}
482
483	/// 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)
484	#[test]
485	fn test_premultiplied_alpha_oklch() {
486		let c1 = Oklch::new(0.1, 0.2, 30.0, 40.0);
487		let c2 = Oklch::new(0.5, 0.6, 70.0, 80.0);
488		let mixed = Oklch::mix(c1, c2, 50.0);
489		assert_close_to!(mixed, Oklch::new(0.36666664, 0.46666664, 50.0, 60.0));
490	}
491
492	/// When both alphas are 100%, premultiplied interpolation == simple interpolation.
493	#[test]
494	fn test_premultiplied_alpha_opaque_same_as_simple() {
495		let c1 = Lab::new(10.0, 20.0, 30.0, 100.0);
496		let c2 = Lab::new(50.0, 60.0, 70.0, 100.0);
497		let mixed = Lab::mix(c1, c2, 50.0);
498		assert_close_to!(mixed, Lab::new(30.0, 40.0, 50.0, 100.0));
499	}
500}