use crate::lanes::{Animatable, Lanes};
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct Rgba {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
}
impl Rgba {
pub const TRANSPARENT: Rgba = Rgba::new(0.0, 0.0, 0.0, 0.0);
pub const BLACK: Rgba = Rgba::new(0.0, 0.0, 0.0, 1.0);
pub const WHITE: Rgba = Rgba::new(1.0, 1.0, 1.0, 1.0);
pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Rgba {
Rgba { r, g, b, a }
}
pub fn from_srgb8(r: u8, g: u8, b: u8, a: u8) -> Rgba {
let c = |v: u8| srgb_to_linear(f32::from(v) / 255.0);
Rgba { r: c(r), g: c(g), b: c(b), a: f32::from(a) / 255.0 }
}
pub fn to_srgb8(self) -> [u8; 4] {
let s = self.sanitized();
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
[byte(linear_to_srgb(s.r)), byte(linear_to_srgb(s.g)), byte(linear_to_srgb(s.b)), byte(s.a)]
}
pub fn sanitized(self) -> Rgba {
let f = |v: f32| if v.is_finite() { v } else { 0.0 };
Rgba { r: f(self.r), g: f(self.g), b: f(self.b), a: f(self.a).clamp(0.0, 1.0) }
}
pub fn clamped(self) -> Rgba {
let s = self.sanitized();
Rgba { r: s.r.clamp(0.0, 1.0), g: s.g.clamp(0.0, 1.0), b: s.b.clamp(0.0, 1.0), a: s.a }
}
pub fn in_gamut(self, tolerance: f32) -> bool {
let ok = |v: f32| v >= -tolerance && v <= 1.0 + tolerance;
ok(self.r) && ok(self.g) && ok(self.b)
}
pub fn with_alpha(self, a: f32) -> Rgba {
Rgba { a: if a.is_nan() { 0.0 } else { a.clamp(0.0, 1.0) }, ..self }
}
pub fn luminance(self) -> f32 {
let c = self.clamped();
0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
}
}
pub fn srgb_to_linear(c: f32) -> f32 {
if c.is_nan() {
0.0
} else if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
pub fn linear_to_srgb(c: f32) -> f32 {
if c.is_nan() {
0.0
} else if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct Oklab {
pub l: f32,
pub a: f32,
pub b: f32,
pub alpha: f32,
}
impl Oklab {
pub fn from_rgba(c: Rgba) -> Oklab {
let [l, a, b] = to_oklab(c);
Oklab { l, a, b, alpha: c.sanitized().a }
}
pub fn to_rgba(self) -> Rgba {
from_oklab([self.l, self.a, self.b], self.alpha)
}
}
impl Animatable for Oklab {
const LANES: usize = 4;
fn to_lanes(self) -> Lanes {
[self.l, self.a, self.b, self.alpha]
}
fn from_lanes([l, a, b, alpha]: Lanes) -> Self {
Oklab { l, a, b, alpha }
}
}
impl Animatable for Rgba {
const LANES: usize = 4;
fn to_lanes(self) -> Lanes {
let c = self.sanitized();
let [l, a, b] = to_oklab(c);
[l * c.a, a * c.a, b * c.a, c.a]
}
fn from_lanes([l, a, b, alpha]: Lanes) -> Self {
let alpha = if alpha.is_nan() { 0.0 } else { alpha.clamp(0.0, 1.0) };
if alpha <= 1e-6 {
return Rgba::TRANSPARENT;
}
from_oklab([l / alpha, a / alpha, b / alpha], alpha).clamped()
}
}
pub fn to_oklab(c: Rgba) -> [f32; 3] {
let c = c.sanitized();
let l = 0.412_221_46 * c.r + 0.536_332_55 * c.g + 0.051_445_995 * c.b;
let m = 0.211_903_5 * c.r + 0.680_699_5 * c.g + 0.107_396_96 * c.b;
let s = 0.088_302_46 * c.r + 0.281_718_85 * c.g + 0.629_978_7 * c.b;
let (l, m, s) = (l.cbrt(), m.cbrt(), s.cbrt());
[
0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
]
}
pub fn from_oklab(lab: [f32; 3], alpha: f32) -> Rgba {
let f = |v: f32| if v.is_finite() { v.clamp(-10.0, 10.0) } else { 0.0 };
let [ll, a, b] = lab.map(f);
let l = ll + 0.396_337_78 * a + 0.215_803_76 * b;
let m = ll - 0.105_561_346 * a - 0.063_854_17 * b;
let s = ll - 0.089_484_18 * a - 1.291_485_5 * b;
let (l, m, s) = (l * l * l, m * m * m, s * s * s);
Rgba {
r: 4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s,
g: -1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s,
b: -0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s,
a: if alpha.is_nan() { 0.0 } else { alpha.clamp(0.0, 1.0) },
}
}
pub const MAX_CHROMA: f32 = 0.4;
pub fn oklch(l: f32, c: f32, h_deg: f32) -> Rgba {
let l = if l.is_nan() { 0.0 } else { l.clamp(0.0, 1.0) };
let c = if c.is_nan() { 0.0 } else { c.clamp(0.0, MAX_CHROMA) };
let h = if h_deg.is_finite() { h_deg.rem_euclid(360.0) } else { 0.0 }.to_radians();
let (sin, cos) = h.sin_cos();
let at = |c: f32| from_oklab([l, c * cos, c * sin], 1.0);
let full = at(c);
if full.in_gamut(1e-5) {
return full.clamped();
}
let (mut lo, mut hi) = (0.0_f32, c);
for _ in 0..24 {
let mid = (lo + hi) * 0.5;
if at(mid).in_gamut(0.0) {
lo = mid;
} else {
hi = mid;
}
}
at(lo).clamped()
}
pub fn to_oklch(c: Rgba) -> [f32; 3] {
let [l, a, b] = to_oklab(c);
let chroma = (a * a + b * b).sqrt();
let h = if chroma < 1e-6 { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
[l, chroma, h]
}
pub fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
let (la, lb) = (a.to_lanes(), b.to_lanes());
let mut out = [0.0; 4];
for (o, (x, y)) in out.iter_mut().zip(la.iter().zip(lb.iter())) {
*o = x + (y - x) * t;
}
Rgba::from_lanes(out)
}
pub fn contrast_ratio(fg: Rgba, bg: Rgba) -> f32 {
let (fg, bg) = (fg.clamped(), bg.clamped());
let over = |f: f32, b: f32| f * fg.a + b * (1.0 - fg.a);
let shown = Rgba::new(over(fg.r, bg.r), over(fg.g, bg.g), over(fg.b, bg.b), 1.0);
let (l1, l2) = (shown.luminance(), bg.luminance());
let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) };
(hi + 0.05) / (lo + 0.05)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_values() {
let w = to_oklab(Rgba::WHITE);
assert!((w[0] - 1.0).abs() < 1e-3 && w[1].abs() < 1e-3 && w[2].abs() < 1e-3);
assert!((contrast_ratio(Rgba::BLACK, Rgba::WHITE) - 21.0).abs() < 1e-3);
assert!((contrast_ratio(Rgba::WHITE, Rgba::WHITE) - 1.0).abs() < 1e-6);
assert_eq!(Rgba::from_srgb8(0x12, 0x34, 0x56, 0x78).to_srgb8(), [0x12, 0x34, 0x56, 0x78]);
let grey = Rgba::from_srgb8(0x77, 0x77, 0x77, 255);
assert!((contrast_ratio(grey, Rgba::WHITE) - 4.48).abs() < 0.01);
}
#[test]
fn mix_ends_and_transparent() {
let red = Rgba::from_srgb8(255, 0, 0, 255);
let blue = Rgba::from_srgb8(0, 0, 255, 255);
let close = |a: Rgba, b: Rgba| a.to_srgb8().iter().zip(b.to_srgb8().iter()).all(|(x, y)| x.abs_diff(*y) <= 1);
assert!(close(mix(red, blue, 0.0), red));
assert!(close(mix(red, blue, 1.0), blue));
let half = mix(red, Rgba::TRANSPARENT, 0.5);
assert!((half.a - 0.5).abs() < 1e-5);
assert!(close(half.with_alpha(1.0), red));
assert!(mix(red, blue, f32::NAN).r.is_finite());
}
#[test]
fn oklch_maps_into_gamut() {
for &(l, c, h) in &[(0.7, 0.4, 145.0), (0.0, 0.4, 0.0), (1.0, 0.4, 270.0), (f32::NAN, f32::INFINITY, f32::NAN), (0.5, -1.0, -720.0)] {
let col = oklch(l, c, h);
assert!(col.in_gamut(0.0) && col.r.is_finite(), "{l} {c} {h} {col:?}");
}
let col = oklch(0.7, 0.4, 145.0);
let [_, _, h] = to_oklch(col);
assert!((h - 145.0).abs() < 1.0, "{h}");
}
}