Skip to main content

culors/spaces/
hsl.rs

1//! HSL color space (cylindrical sRGB).
2//!
3//! Conversions track culori 4.0.2 (`node_modules/culori/src/hsl/`). HSL is a
4//! direct cylindrical reparameterization of sRGB; no XYZ trip is involved.
5//! `to_xyz65` / `from_xyz65` simply compose with the [`Rgb`] hub conversion.
6//!
7//! culori omits the `h` property when chroma is zero (achromatic colors). We
8//! mirror that with `f64::NAN` since our struct stores `h` as `f64`. On the
9//! reverse path, NaN hue is treated as 0 (matching culori's `h ?? 0`
10//! fallback inside `convertHslToRgb`).
11
12use crate::spaces::{Rgb, Xyz65};
13use crate::traits::ColorSpace;
14
15/// HSL — hue (degrees, 0..360), saturation (0..1), lightness (0..1). For
16/// achromatic colors `h` is NaN.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct Hsl {
19    /// Hue in degrees, NaN for achromatic colors.
20    pub h: f64,
21    /// Saturation in 0..1.
22    pub s: f64,
23    /// Lightness in 0..1.
24    pub l: f64,
25    /// Optional alpha in 0..1.
26    pub alpha: Option<f64>,
27}
28
29#[inline]
30fn normalize_hue(h: f64) -> f64 {
31    let h = h % 360.0;
32    if h < 0.0 {
33        h + 360.0
34    } else {
35        h
36    }
37}
38
39impl ColorSpace for Hsl {
40    const MODE: &'static str = "hsl";
41    const CHANNELS: &'static [&'static str] = &["h", "s", "l"];
42
43    fn alpha(&self) -> Option<f64> {
44        self.alpha
45    }
46
47    fn with_alpha(self, alpha: Option<f64>) -> Self {
48        Self { alpha, ..self }
49    }
50
51    fn to_xyz65(&self) -> Xyz65 {
52        Rgb::from(*self).to_xyz65()
53    }
54
55    fn from_xyz65(xyz: Xyz65) -> Self {
56        Rgb::from_xyz65(xyz).into()
57    }
58}
59
60impl From<Rgb> for Hsl {
61    fn from(c: Rgb) -> Self {
62        let Rgb { r, g, b, alpha } = c;
63        let max = r.max(g).max(b);
64        let min = r.min(g).min(b);
65        let l = 0.5 * (max + min);
66        let s = if max == min {
67            0.0
68        } else {
69            (max - min) / (1.0 - (max + min - 1.0).abs())
70        };
71        let h = if max == min {
72            f64::NAN
73        } else if max == r {
74            let mut h = (g - b) / (max - min);
75            if g < b {
76                h += 6.0;
77            }
78            h * 60.0
79        } else if max == g {
80            ((b - r) / (max - min) + 2.0) * 60.0
81        } else {
82            ((r - g) / (max - min) + 4.0) * 60.0
83        };
84        Self { h, s, l, alpha }
85    }
86}
87
88impl From<Hsl> for Rgb {
89    fn from(c: Hsl) -> Self {
90        // culori normalizes h via `h !== undefined ? h : 0`; for our NaN
91        // sentinel we coerce to 0 before normalizing.
92        let h_in = if c.h.is_nan() { 0.0 } else { c.h };
93        let h = normalize_hue(h_in);
94        let s = c.s;
95        let l = c.l;
96        let m1 = l + s * (if l < 0.5 { l } else { 1.0 - l });
97        let m2 = m1 - (m1 - l) * 2.0 * (((h / 60.0) % 2.0) - 1.0).abs();
98        let (r, g, b) = match (h / 60.0).floor() as i32 {
99            0 => (m1, m2, 2.0 * l - m1),
100            1 => (m2, m1, 2.0 * l - m1),
101            2 => (2.0 * l - m1, m1, m2),
102            3 => (2.0 * l - m1, m2, m1),
103            4 => (m2, 2.0 * l - m1, m1),
104            5 => (m1, 2.0 * l - m1, m2),
105            _ => (2.0 * l - m1, 2.0 * l - m1, 2.0 * l - m1),
106        };
107        Self {
108            r,
109            g,
110            b,
111            alpha: c.alpha,
112        }
113    }
114}