Skip to main content

esoc_color/
color.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Core color type: f32 linear RGBA.
3
4use crate::oklab::{OkLab, OkLch};
5use crate::srgb;
6
7/// An RGBA color with f32 channels in linear space.
8///
9/// This is the core color type, designed for GPU upload (`#[repr(C)]`).
10/// Channels are in `[0.0, 1.0]` linear light (not sRGB gamma-encoded).
11#[derive(Clone, Copy, Debug, PartialEq)]
12#[repr(C)]
13pub struct Color {
14    /// Red channel (linear).
15    pub r: f32,
16    /// Green channel (linear).
17    pub g: f32,
18    /// Blue channel (linear).
19    pub b: f32,
20    /// Alpha channel (1.0 = fully opaque).
21    pub a: f32,
22}
23
24impl Color {
25    /// Fully transparent black.
26    pub const TRANSPARENT: Self = Self {
27        r: 0.0,
28        g: 0.0,
29        b: 0.0,
30        a: 0.0,
31    };
32    /// Black.
33    pub const BLACK: Self = Self {
34        r: 0.0,
35        g: 0.0,
36        b: 0.0,
37        a: 1.0,
38    };
39    /// White.
40    pub const WHITE: Self = Self {
41        r: 1.0,
42        g: 1.0,
43        b: 1.0,
44        a: 1.0,
45    };
46    /// 50% gray (linear).
47    pub const GRAY: Self = Self {
48        r: 0.5,
49        g: 0.5,
50        b: 0.5,
51        a: 1.0,
52    };
53    /// Red.
54    pub const RED: Self = Self {
55        r: 1.0,
56        g: 0.0,
57        b: 0.0,
58        a: 1.0,
59    };
60    /// Green.
61    pub const GREEN: Self = Self {
62        r: 0.0,
63        g: 0.5,
64        b: 0.0,
65        a: 1.0,
66    };
67    /// Blue.
68    pub const BLUE: Self = Self {
69        r: 0.0,
70        g: 0.0,
71        b: 1.0,
72        a: 1.0,
73    };
74
75    /// Create from linear RGBA channels.
76    pub fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
77        Self { r, g, b, a }
78    }
79
80    /// Create an opaque color from linear RGB.
81    pub fn rgb(r: f32, g: f32, b: f32) -> Self {
82        Self { r, g, b, a: 1.0 }
83    }
84
85    /// Create from sRGB 8-bit values (gamma-decoded to linear).
86    pub fn from_srgb8(r: u8, g: u8, b: u8) -> Self {
87        Self {
88            r: srgb::decode(f32::from(r) / 255.0),
89            g: srgb::decode(f32::from(g) / 255.0),
90            b: srgb::decode(f32::from(b) / 255.0),
91            a: 1.0,
92        }
93    }
94
95    /// Create from an sRGB hex string (`#RRGGBB` or `#RRGGBBAA`).
96    pub fn from_hex(hex: &str) -> Option<Self> {
97        let hex = hex.strip_prefix('#').unwrap_or(hex);
98        let parse_byte = |s: &str| u8::from_str_radix(s, 16).ok();
99
100        match hex.len() {
101            6 => {
102                let r = parse_byte(&hex[0..2])?;
103                let g = parse_byte(&hex[2..4])?;
104                let b = parse_byte(&hex[4..6])?;
105                Some(Self::from_srgb8(r, g, b))
106            }
107            8 => {
108                let r = parse_byte(&hex[0..2])?;
109                let g = parse_byte(&hex[2..4])?;
110                let b = parse_byte(&hex[4..6])?;
111                let a = parse_byte(&hex[6..8])?;
112                let mut c = Self::from_srgb8(r, g, b);
113                c.a = f32::from(a) / 255.0;
114                Some(c)
115            }
116            _ => None,
117        }
118    }
119
120    /// Return this color with a new alpha value.
121    pub fn with_alpha(mut self, a: f32) -> Self {
122        self.a = a;
123        self
124    }
125
126    /// Linearly interpolate in linear RGB space.
127    pub fn lerp(self, other: Self, t: f32) -> Self {
128        let t = t.clamp(0.0, 1.0);
129        Self {
130            r: self.r + (other.r - self.r) * t,
131            g: self.g + (other.g - self.g) * t,
132            b: self.b + (other.b - self.b) * t,
133            a: self.a + (other.a - self.a) * t,
134        }
135    }
136
137    /// Interpolate in `OKLab` perceptual space (better for gradients).
138    pub fn lerp_oklab(self, other: Self, t: f32) -> Self {
139        let a = OkLab::from_linear_rgb(self);
140        let b = OkLab::from_linear_rgb(other);
141        let mixed = a.lerp(b, t);
142        let mut c = mixed.to_linear_rgb();
143        c.a = self.a + (other.a - self.a) * t.clamp(0.0, 1.0);
144        c
145    }
146
147    /// Convert to sRGB 8-bit (gamma-encoded).
148    pub fn to_srgb8(self) -> [u8; 4] {
149        [
150            (srgb::encode(self.r) * 255.0 + 0.5) as u8,
151            (srgb::encode(self.g) * 255.0 + 0.5) as u8,
152            (srgb::encode(self.b) * 255.0 + 0.5) as u8,
153            (self.a * 255.0 + 0.5) as u8,
154        ]
155    }
156
157    /// Format as sRGB hex string (`#RRGGBB`).
158    pub fn to_hex(self) -> String {
159        let [r, g, b, _] = self.to_srgb8();
160        format!("#{r:02x}{g:02x}{b:02x}")
161    }
162
163    /// Format as an SVG color string.
164    pub fn to_svg_string(self) -> String {
165        let [r, g, b, _] = self.to_srgb8();
166        if (self.a - 1.0).abs() < 1e-6 {
167            format!("rgb({r},{g},{b})")
168        } else {
169            format!("rgba({r},{g},{b},{:.3})", self.a)
170        }
171    }
172
173    /// Convert to `OKLab`.
174    pub fn to_oklab(self) -> OkLab {
175        OkLab::from_linear_rgb(self)
176    }
177
178    /// Convert to OKLCH.
179    pub fn to_oklch(self) -> OkLch {
180        self.to_oklab().to_oklch()
181    }
182
183    /// Create from `OKLab`.
184    pub fn from_oklab(lab: OkLab) -> Self {
185        lab.to_linear_rgb()
186    }
187
188    /// Create from OKLCH.
189    pub fn from_oklch(lch: OkLch) -> Self {
190        lch.to_oklab().to_linear_rgb()
191    }
192
193    /// Raw f32x4 array for GPU upload.
194    pub fn to_array(self) -> [f32; 4] {
195        [self.r, self.g, self.b, self.a]
196    }
197}
198
199impl Default for Color {
200    fn default() -> Self {
201        Self::BLACK
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn hex_roundtrip() {
211        let c = Color::from_hex("#1f77b4").unwrap();
212        let hex = c.to_hex();
213        assert_eq!(hex, "#1f77b4");
214    }
215
216    #[test]
217    fn hex_with_alpha() {
218        let c = Color::from_hex("#ff000080").unwrap();
219        assert!((c.a - 128.0 / 255.0).abs() < 0.01);
220    }
221
222    #[test]
223    fn hex_invalid() {
224        assert!(Color::from_hex("#gg0000").is_none());
225        assert!(Color::from_hex("#123").is_none());
226    }
227
228    #[test]
229    fn lerp_midpoint() {
230        let mid = Color::BLACK.lerp(Color::WHITE, 0.5);
231        assert!((mid.r - 0.5).abs() < 1e-6);
232    }
233
234    #[test]
235    fn svg_string() {
236        let c = Color::from_hex("#ff0000").unwrap();
237        assert_eq!(c.to_svg_string(), "rgb(255,0,0)");
238    }
239
240    #[test]
241    fn oklab_roundtrip() {
242        let c = Color::from_hex("#1f77b4").unwrap();
243        let lab = c.to_oklab();
244        let back = Color::from_oklab(lab);
245        assert!((c.r - back.r).abs() < 1e-4);
246        assert!((c.g - back.g).abs() < 1e-4);
247        assert!((c.b - back.b).abs() < 1e-4);
248    }
249}