1use crate::lanes::{Animatable, Lanes};
8
9#[derive(Clone, Copy, Debug, PartialEq, Default)]
11pub struct Rgba {
12 pub r: f32,
14 pub g: f32,
16 pub b: f32,
18 pub a: f32,
20}
21
22impl Rgba {
23 pub const TRANSPARENT: Rgba = Rgba::new(0.0, 0.0, 0.0, 0.0);
25 pub const BLACK: Rgba = Rgba::new(0.0, 0.0, 0.0, 1.0);
27 pub const WHITE: Rgba = Rgba::new(1.0, 1.0, 1.0, 1.0);
29
30 pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Rgba {
32 Rgba { r, g, b, a }
33 }
34
35 pub fn from_srgb8(r: u8, g: u8, b: u8, a: u8) -> Rgba {
37 let c = |v: u8| srgb_to_linear(f32::from(v) / 255.0);
38 Rgba { r: c(r), g: c(g), b: c(b), a: f32::from(a) / 255.0 }
39 }
40
41 pub fn to_srgb8(self) -> [u8; 4] {
43 let s = self.sanitized();
44 let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
45 [byte(linear_to_srgb(s.r)), byte(linear_to_srgb(s.g)), byte(linear_to_srgb(s.b)), byte(s.a)]
46 }
47
48 pub fn sanitized(self) -> Rgba {
50 let f = |v: f32| if v.is_finite() { v } else { 0.0 };
51 Rgba { r: f(self.r), g: f(self.g), b: f(self.b), a: f(self.a).clamp(0.0, 1.0) }
52 }
53
54 pub fn clamped(self) -> Rgba {
56 let s = self.sanitized();
57 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 }
58 }
59
60 pub fn in_gamut(self, tolerance: f32) -> bool {
62 let ok = |v: f32| v >= -tolerance && v <= 1.0 + tolerance;
63 ok(self.r) && ok(self.g) && ok(self.b)
64 }
65
66 pub fn with_alpha(self, a: f32) -> Rgba {
68 Rgba { a: if a.is_nan() { 0.0 } else { a.clamp(0.0, 1.0) }, ..self }
69 }
70
71 pub fn luminance(self) -> f32 {
73 let c = self.clamped();
74 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
75 }
76}
77
78pub fn srgb_to_linear(c: f32) -> f32 {
80 if c.is_nan() {
81 0.0
82 } else if c <= 0.04045 {
83 c / 12.92
84 } else {
85 ((c + 0.055) / 1.055).powf(2.4)
86 }
87}
88
89pub fn linear_to_srgb(c: f32) -> f32 {
91 if c.is_nan() {
92 0.0
93 } else if c <= 0.003_130_8 {
94 c * 12.92
95 } else {
96 1.055 * c.powf(1.0 / 2.4) - 0.055
97 }
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Default)]
102pub struct Oklab {
103 pub l: f32,
105 pub a: f32,
107 pub b: f32,
109 pub alpha: f32,
111}
112
113impl Oklab {
114 pub fn from_rgba(c: Rgba) -> Oklab {
116 let [l, a, b] = to_oklab(c);
117 Oklab { l, a, b, alpha: c.sanitized().a }
118 }
119
120 pub fn to_rgba(self) -> Rgba {
122 from_oklab([self.l, self.a, self.b], self.alpha)
123 }
124}
125
126impl Animatable for Oklab {
127 const LANES: usize = 4;
128 fn to_lanes(self) -> Lanes {
129 [self.l, self.a, self.b, self.alpha]
130 }
131 fn from_lanes([l, a, b, alpha]: Lanes) -> Self {
132 Oklab { l, a, b, alpha }
133 }
134}
135
136impl Animatable for Rgba {
139 const LANES: usize = 4;
140 fn to_lanes(self) -> Lanes {
141 let c = self.sanitized();
142 let [l, a, b] = to_oklab(c);
143 [l * c.a, a * c.a, b * c.a, c.a]
144 }
145 fn from_lanes([l, a, b, alpha]: Lanes) -> Self {
146 let alpha = if alpha.is_nan() { 0.0 } else { alpha.clamp(0.0, 1.0) };
147 if alpha <= 1e-6 {
148 return Rgba::TRANSPARENT;
149 }
150 from_oklab([l / alpha, a / alpha, b / alpha], alpha).clamped()
151 }
152}
153
154pub fn to_oklab(c: Rgba) -> [f32; 3] {
156 let c = c.sanitized();
157 let l = 0.412_221_46 * c.r + 0.536_332_55 * c.g + 0.051_445_995 * c.b;
158 let m = 0.211_903_5 * c.r + 0.680_699_5 * c.g + 0.107_396_96 * c.b;
159 let s = 0.088_302_46 * c.r + 0.281_718_85 * c.g + 0.629_978_7 * c.b;
160 let (l, m, s) = (l.cbrt(), m.cbrt(), s.cbrt());
161 [
162 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
163 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
164 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
165 ]
166}
167
168pub fn from_oklab(lab: [f32; 3], alpha: f32) -> Rgba {
171 let f = |v: f32| if v.is_finite() { v.clamp(-10.0, 10.0) } else { 0.0 };
172 let [ll, a, b] = lab.map(f);
173 let l = ll + 0.396_337_78 * a + 0.215_803_76 * b;
174 let m = ll - 0.105_561_346 * a - 0.063_854_17 * b;
175 let s = ll - 0.089_484_18 * a - 1.291_485_5 * b;
176 let (l, m, s) = (l * l * l, m * m * m, s * s * s);
177 Rgba {
178 r: 4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s,
179 g: -1.268_438 * l + 2.609_757_4 * m - 0.341_319_38 * s,
180 b: -0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s,
181 a: if alpha.is_nan() { 0.0 } else { alpha.clamp(0.0, 1.0) },
182 }
183}
184
185pub const MAX_CHROMA: f32 = 0.4;
187
188pub fn oklch(l: f32, c: f32, h_deg: f32) -> Rgba {
191 let l = if l.is_nan() { 0.0 } else { l.clamp(0.0, 1.0) };
192 let c = if c.is_nan() { 0.0 } else { c.clamp(0.0, MAX_CHROMA) };
193 let h = if h_deg.is_finite() { h_deg.rem_euclid(360.0) } else { 0.0 }.to_radians();
194 let (sin, cos) = h.sin_cos();
195 let at = |c: f32| from_oklab([l, c * cos, c * sin], 1.0);
196 let full = at(c);
197 if full.in_gamut(1e-5) {
198 return full.clamped();
199 }
200 let (mut lo, mut hi) = (0.0_f32, c);
202 for _ in 0..24 {
203 let mid = (lo + hi) * 0.5;
204 if at(mid).in_gamut(0.0) {
205 lo = mid;
206 } else {
207 hi = mid;
208 }
209 }
210 at(lo).clamped()
211}
212
213pub fn to_oklch(c: Rgba) -> [f32; 3] {
215 let [l, a, b] = to_oklab(c);
216 let chroma = (a * a + b * b).sqrt();
217 let h = if chroma < 1e-6 { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
218 [l, chroma, h]
219}
220
221pub fn mix(a: Rgba, b: Rgba, t: f32) -> Rgba {
224 let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
225 let (la, lb) = (a.to_lanes(), b.to_lanes());
226 let mut out = [0.0; 4];
227 for (o, (x, y)) in out.iter_mut().zip(la.iter().zip(lb.iter())) {
228 *o = x + (y - x) * t;
229 }
230 Rgba::from_lanes(out)
231}
232
233pub fn contrast_ratio(fg: Rgba, bg: Rgba) -> f32 {
236 let (fg, bg) = (fg.clamped(), bg.clamped());
237 let over = |f: f32, b: f32| f * fg.a + b * (1.0 - fg.a);
238 let shown = Rgba::new(over(fg.r, bg.r), over(fg.g, bg.g), over(fg.b, bg.b), 1.0);
239 let (l1, l2) = (shown.luminance(), bg.luminance());
240 let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) };
241 (hi + 0.05) / (lo + 0.05)
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn known_values() {
250 let w = to_oklab(Rgba::WHITE);
251 assert!((w[0] - 1.0).abs() < 1e-3 && w[1].abs() < 1e-3 && w[2].abs() < 1e-3);
252 assert!((contrast_ratio(Rgba::BLACK, Rgba::WHITE) - 21.0).abs() < 1e-3);
253 assert!((contrast_ratio(Rgba::WHITE, Rgba::WHITE) - 1.0).abs() < 1e-6);
254 assert_eq!(Rgba::from_srgb8(0x12, 0x34, 0x56, 0x78).to_srgb8(), [0x12, 0x34, 0x56, 0x78]);
255 let grey = Rgba::from_srgb8(0x77, 0x77, 0x77, 255);
257 assert!((contrast_ratio(grey, Rgba::WHITE) - 4.48).abs() < 0.01);
258 }
259
260 #[test]
261 fn mix_ends_and_transparent() {
262 let red = Rgba::from_srgb8(255, 0, 0, 255);
263 let blue = Rgba::from_srgb8(0, 0, 255, 255);
264 let close = |a: Rgba, b: Rgba| a.to_srgb8().iter().zip(b.to_srgb8().iter()).all(|(x, y)| x.abs_diff(*y) <= 1);
265 assert!(close(mix(red, blue, 0.0), red));
266 assert!(close(mix(red, blue, 1.0), blue));
267 let half = mix(red, Rgba::TRANSPARENT, 0.5);
269 assert!((half.a - 0.5).abs() < 1e-5);
270 assert!(close(half.with_alpha(1.0), red));
271 assert!(mix(red, blue, f32::NAN).r.is_finite());
272 }
273
274 #[test]
275 fn oklch_maps_into_gamut() {
276 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)] {
277 let col = oklch(l, c, h);
278 assert!(col.in_gamut(0.0) && col.r.is_finite(), "{l} {c} {h} {col:?}");
279 }
280 let col = oklch(0.7, 0.4, 145.0);
282 let [_, _, h] = to_oklch(col);
283 assert!((h - 145.0).abs() < 1.0, "{h}");
284 }
285}