Skip to main content

trueno_viz/
color.rs

1//! Color types and color space conversions.
2//!
3//! Provides RGBA and HSLA color representations with conversions between them.
4//! Implements perceptually uniform color spaces for scientific accuracy.
5//!
6//! # References
7//!
8//! - Sharma, G., Wu, W., & Dalal, E. N. (2005). "The CIEDE2000 Color-Difference Formula."
9//!   *Color Research & Application*, 30(1), 21-30.
10
11/// RGBA color with 8-bit components.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13#[repr(C)]
14pub struct Rgba {
15    /// Red component (0-255).
16    pub r: u8,
17    /// Green component (0-255).
18    pub g: u8,
19    /// Blue component (0-255).
20    pub b: u8,
21    /// Alpha component (0-255, 255 = fully opaque).
22    pub a: u8,
23}
24
25impl Rgba {
26    /// Fully transparent black.
27    pub const TRANSPARENT: Self = Self::new(0, 0, 0, 0);
28    /// Opaque black.
29    pub const BLACK: Self = Self::new(0, 0, 0, 255);
30    /// Opaque white.
31    pub const WHITE: Self = Self::new(255, 255, 255, 255);
32    /// Opaque red.
33    pub const RED: Self = Self::new(255, 0, 0, 255);
34    /// Opaque green.
35    pub const GREEN: Self = Self::new(0, 255, 0, 255);
36    /// Opaque blue.
37    pub const BLUE: Self = Self::new(0, 0, 255, 255);
38
39    /// Create a new RGBA color.
40    #[must_use]
41    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
42        Self { r, g, b, a }
43    }
44
45    /// Create an opaque RGB color (alpha = 255).
46    #[must_use]
47    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
48        Self::new(r, g, b, 255)
49    }
50
51    /// Create a color with modified alpha.
52    #[must_use]
53    pub const fn with_alpha(self, a: u8) -> Self {
54        Self::new(self.r, self.g, self.b, a)
55    }
56
57    /// Convert to array representation.
58    #[must_use]
59    pub const fn to_array(self) -> [u8; 4] {
60        [self.r, self.g, self.b, self.a]
61    }
62
63    /// Create from array representation.
64    #[must_use]
65    pub const fn from_array(arr: [u8; 4]) -> Self {
66        Self::new(arr[0], arr[1], arr[2], arr[3])
67    }
68
69    /// Linear interpolation between two colors.
70    #[must_use]
71    pub fn lerp(self, other: Self, t: f32) -> Self {
72        let t = t.clamp(0.0, 1.0);
73        let inv_t = 1.0 - t;
74
75        Self::new(
76            (f32::from(self.r) * inv_t + f32::from(other.r) * t) as u8,
77            (f32::from(self.g) * inv_t + f32::from(other.g) * t) as u8,
78            (f32::from(self.b) * inv_t + f32::from(other.b) * t) as u8,
79            (f32::from(self.a) * inv_t + f32::from(other.a) * t) as u8,
80        )
81    }
82}
83
84/// HSLA color with floating-point components.
85#[derive(Debug, Clone, Copy, PartialEq, Default)]
86pub struct Hsla {
87    /// Hue (0.0-360.0 degrees).
88    pub h: f32,
89    /// Saturation (0.0-1.0).
90    pub s: f32,
91    /// Lightness (0.0-1.0).
92    pub l: f32,
93    /// Alpha (0.0-1.0).
94    pub a: f32,
95}
96
97impl Hsla {
98    /// Create a new HSLA color.
99    #[must_use]
100    pub const fn new(h: f32, s: f32, l: f32, a: f32) -> Self {
101        Self { h, s, l, a }
102    }
103
104    /// Create an opaque HSL color (alpha = 1.0).
105    #[must_use]
106    pub const fn hsl(h: f32, s: f32, l: f32) -> Self {
107        Self::new(h, s, l, 1.0)
108    }
109
110    /// Convert to RGBA.
111    #[must_use]
112    pub fn to_rgba(self) -> Rgba {
113        let h = self.h / 360.0;
114        let s = self.s;
115        let l = self.l;
116
117        let (r, g, b) = if s == 0.0 {
118            (l, l, l)
119        } else {
120            let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
121            let p = 2.0 * l - q;
122
123            (hue_to_rgb(p, q, h + 1.0 / 3.0), hue_to_rgb(p, q, h), hue_to_rgb(p, q, h - 1.0 / 3.0))
124        };
125
126        Rgba::new((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, (self.a * 255.0) as u8)
127    }
128}
129
130fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 {
131    if t < 0.0 {
132        t += 1.0;
133    }
134    if t > 1.0 {
135        t -= 1.0;
136    }
137
138    if t < 1.0 / 6.0 {
139        p + (q - p) * 6.0 * t
140    } else if t < 1.0 / 2.0 {
141        q
142    } else if t < 2.0 / 3.0 {
143        p + (q - p) * (2.0 / 3.0 - t) * 6.0
144    } else {
145        p
146    }
147}
148
149impl From<Hsla> for Rgba {
150    fn from(hsla: Hsla) -> Self {
151        hsla.to_rgba()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_rgba_constants() {
161        assert_eq!(Rgba::BLACK, Rgba::rgb(0, 0, 0));
162        assert_eq!(Rgba::WHITE, Rgba::rgb(255, 255, 255));
163        assert_eq!(Rgba::RED.r, 255);
164        assert_eq!(Rgba::GREEN.g, 255);
165        assert_eq!(Rgba::BLUE.b, 255);
166    }
167
168    #[test]
169    fn test_rgba_lerp() {
170        let black = Rgba::BLACK;
171        let white = Rgba::WHITE;
172
173        let mid = black.lerp(white, 0.5);
174        assert_eq!(mid.r, 127);
175        assert_eq!(mid.g, 127);
176        assert_eq!(mid.b, 127);
177    }
178
179    #[test]
180    fn test_hsla_to_rgba() {
181        // Red
182        let red = Hsla::hsl(0.0, 1.0, 0.5).to_rgba();
183        assert_eq!(red.r, 255);
184        assert_eq!(red.g, 0);
185        assert_eq!(red.b, 0);
186
187        // Gray (saturation = 0)
188        let gray = Hsla::hsl(0.0, 0.0, 0.5).to_rgba();
189        assert_eq!(gray.r, 127);
190        assert_eq!(gray.g, 127);
191        assert_eq!(gray.b, 127);
192    }
193
194    #[test]
195    fn test_hsla_to_rgba_low_lightness() {
196        // Dark red (l < 0.5, tests line 121: l * (1.0 + s))
197        let dark_red = Hsla::hsl(0.0, 1.0, 0.25).to_rgba();
198        assert_eq!(dark_red.r, 127);
199        assert_eq!(dark_red.g, 0);
200        assert_eq!(dark_red.b, 0);
201    }
202
203    #[test]
204    fn test_hsla_to_rgba_high_hue() {
205        // High hue value that causes t > 1.0 in hue_to_rgb (tests line 148)
206        // h=300 degrees -> normalized h=0.833
207        // t values: 0.833+0.333=1.166 (needs reduction), 0.833, 0.833-0.333=0.5
208        let magenta = Hsla::hsl(300.0, 1.0, 0.5).to_rgba();
209        // Allow for floating point rounding (254 or 255)
210        assert!(magenta.r >= 254);
211        assert_eq!(magenta.g, 0);
212        assert!(magenta.b >= 254);
213    }
214
215    #[test]
216    fn test_hsla_to_rgba_cyan() {
217        // Cyan: h=180 degrees
218        // Tests different branch paths in hue_to_rgb (t >= 2/3 branch, line 158)
219        let cyan = Hsla::hsl(180.0, 1.0, 0.5).to_rgba();
220        assert_eq!(cyan.r, 0);
221        // Allow for floating point rounding (254 or 255)
222        assert!(cyan.g >= 254);
223        assert!(cyan.b >= 254);
224    }
225
226    #[test]
227    fn test_from_hsla_trait() {
228        // Test From<Hsla> for Rgba (lines 163-165)
229        let hsla = Hsla::hsl(0.0, 1.0, 0.5);
230        let rgba: Rgba = hsla.into();
231        assert_eq!(rgba.r, 255);
232        assert_eq!(rgba.g, 0);
233        assert_eq!(rgba.b, 0);
234    }
235
236    #[test]
237    fn test_rgba_with_alpha() {
238        let red = Rgba::RED;
239        let semi_red = red.with_alpha(128);
240        assert_eq!(semi_red.r, 255);
241        assert_eq!(semi_red.a, 128);
242    }
243
244    #[test]
245    fn test_rgba_to_array_from_array() {
246        let color = Rgba::new(10, 20, 30, 40);
247        let arr = color.to_array();
248        assert_eq!(arr, [10, 20, 30, 40]);
249        let restored = Rgba::from_array(arr);
250        assert_eq!(restored, color);
251    }
252
253    #[test]
254    fn test_hsla_new() {
255        let hsla = Hsla::new(180.0, 0.5, 0.5, 0.8);
256        assert!((hsla.h - 180.0).abs() < f32::EPSILON);
257        assert!((hsla.s - 0.5).abs() < f32::EPSILON);
258        assert!((hsla.l - 0.5).abs() < f32::EPSILON);
259        assert!((hsla.a - 0.8).abs() < f32::EPSILON);
260    }
261
262    #[test]
263    fn test_rgba_default() {
264        let color = Rgba::default();
265        assert_eq!(color, Rgba::new(0, 0, 0, 0));
266    }
267
268    #[test]
269    fn test_hsla_default() {
270        let color = Hsla::default();
271        assert!((color.h - 0.0).abs() < f32::EPSILON);
272        assert!((color.s - 0.0).abs() < f32::EPSILON);
273    }
274
275    #[test]
276    fn test_rgba_transparent() {
277        assert_eq!(Rgba::TRANSPARENT, Rgba::new(0, 0, 0, 0));
278        assert_eq!(Rgba::TRANSPARENT.a, 0);
279    }
280
281    #[test]
282    fn test_lerp_boundaries() {
283        let black = Rgba::BLACK;
284        let white = Rgba::WHITE;
285
286        // t=0 should give black
287        let at_zero = black.lerp(white, 0.0);
288        assert_eq!(at_zero, black);
289
290        // t=1 should give white
291        let at_one = black.lerp(white, 1.0);
292        assert_eq!(at_one, white);
293
294        // t clamped to [0, 1]
295        let below = black.lerp(white, -0.5);
296        assert_eq!(below, black);
297
298        let above = black.lerp(white, 1.5);
299        assert_eq!(above, white);
300    }
301}