use peniko::color::{AlphaColor, Oklab};
pub use peniko::Color;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ColorSpace {
#[default]
Oklab,
Srgb,
}
pub fn rgb(r: f32, g: f32, b: f32) -> Color {
Color::new([r, g, b, 1.0])
}
pub fn rgba(r: f32, g: f32, b: f32, a: f32) -> Color {
Color::new([r, g, b, a])
}
pub fn rgb8(r: u8, g: u8, b: u8) -> Color {
rgb(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0)
}
pub fn lerp_color(a: Color, b: Color, t: f64, space: ColorSpace) -> Color {
let t = t as f32;
match space {
ColorSpace::Srgb => {
let [ar, ag, ab, aa] = a.components;
let [br, bg, bb, ba] = b.components;
Color::new([
ar + t * (br - ar),
ag + t * (bg - ag),
ab + t * (bb - ab),
aa + t * (ba - aa),
])
}
ColorSpace::Oklab => {
if t == 0.0 || a == b {
return a;
}
if t == 1.0 {
return b;
}
let [al, aa_, ab_, aalpha] = a.convert::<Oklab>().components;
let [bl, ba_, bb_, balpha] = b.convert::<Oklab>().components;
let mixed = AlphaColor::<Oklab>::new([
al + t * (bl - al),
aa_ + t * (ba_ - aa_),
ab_ + t * (bb_ - ab_),
aalpha + t * (balpha - aalpha),
]);
let [r, g, bch, alpha] = mixed.convert::<peniko::color::Srgb>().components;
Color::new([
r.clamp(0.0, 1.0),
g.clamp(0.0, 1.0),
bch.clamp(0.0, 1.0),
alpha.clamp(0.0, 1.0),
])
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: Color, b: Color, tol: f32) -> bool {
a.components
.iter()
.zip(b.components.iter())
.all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn endpoints_are_exact_in_both_spaces() {
let a = rgb8(255, 0, 0);
let b = rgb8(0, 0, 255);
for space in [ColorSpace::Srgb, ColorSpace::Oklab] {
assert!(approx(lerp_color(a, b, 0.0, space), a, 1e-3));
assert!(approx(lerp_color(a, b, 1.0, space), b, 1e-3));
}
}
#[test]
fn srgb_midpoint_is_the_channel_average() {
let mid = lerp_color(
rgb(0.0, 0.0, 0.0),
rgb(1.0, 0.5, 0.25),
0.5,
ColorSpace::Srgb,
);
assert!(approx(mid, rgb(0.5, 0.25, 0.125), 1e-6));
}
#[test]
fn oklab_midpoint_is_lighter_than_the_srgb_one() {
let a = rgb8(255, 0, 0);
let b = rgb8(0, 0, 255);
let ok = lerp_color(a, b, 0.5, ColorSpace::Oklab);
let srgb = lerp_color(a, b, 0.5, ColorSpace::Srgb);
let lightness = |c: Color| c.convert::<Oklab>().components[0];
assert!(
lightness(ok) > lightness(srgb) + 0.05,
"oklab {:?} should hold more lightness than srgb {:?}",
ok,
srgb
);
}
#[test]
fn oklab_interpolates_gray_without_a_hue_cast() {
let mid = lerp_color(
rgb(0.0, 0.0, 0.0),
rgb(1.0, 1.0, 1.0),
0.5,
ColorSpace::Oklab,
);
let [r, g, b, _] = mid.components;
assert!((r - g).abs() < 1e-3 && (g - b).abs() < 1e-3);
}
#[test]
fn alpha_interpolates_the_same_way_in_both_spaces() {
let a = rgba(1.0, 0.0, 0.0, 1.0);
let b = rgba(0.0, 0.0, 1.0, 0.0);
for space in [ColorSpace::Srgb, ColorSpace::Oklab] {
let mid = lerp_color(a, b, 0.25, space);
assert!((mid.components[3] - 0.75).abs() < 1e-3);
}
}
#[test]
fn oklab_results_stay_in_gamut_under_extrapolation() {
let out = lerp_color(rgb8(255, 0, 0), rgb8(0, 0, 255), 2.5, ColorSpace::Oklab);
assert!(out.components.iter().all(|c| (0.0..=1.0).contains(c)));
}
#[test]
fn default_space_is_oklab() {
assert_eq!(ColorSpace::default(), ColorSpace::Oklab);
}
}