Skip to main content

bevy_color/
lcha.rs

1use crate::{
2    Alpha, ColorToComponents, Gray, Hue, Laba, LinearRgba, Luminance, Mix, Srgba, StandardColor,
3    Xyza,
4};
5use bevy_math::{ops, Vec3, Vec4};
6#[cfg(feature = "bevy_reflect")]
7use bevy_reflect::prelude::*;
8
9/// Color in LCH color space, with alpha
10#[doc = include_str!("../docs/conversion.md")]
11/// <div>
12#[doc = include_str!("../docs/diagrams/model_graph.svg")]
13/// </div>
14#[derive(Debug, Clone, Copy, PartialEq)]
15#[cfg_attr(
16    feature = "bevy_reflect",
17    derive(Reflect),
18    reflect(Clone, PartialEq, Default)
19)]
20#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
21#[cfg_attr(
22    all(feature = "serialize", feature = "bevy_reflect"),
23    reflect(Serialize, Deserialize)
24)]
25pub struct Lcha {
26    /// The lightness channel. [0.0, 1.5]
27    pub lightness: f32,
28    /// The chroma channel. Typically [0.0, 1.5]
29    pub chroma: f32,
30    /// The hue channel. [0.0, 360.0]
31    pub hue: f32,
32    /// The alpha channel. [0.0, 1.0]
33    pub alpha: f32,
34}
35
36impl StandardColor for Lcha {}
37
38impl Lcha {
39    /// Construct a new [`Lcha`] color from components.
40    ///
41    /// # Arguments
42    ///
43    /// * `lightness` - Lightness channel. [0.0, 1.5]
44    /// * `chroma` - Chroma channel. Typically [0.0, 1.5]
45    /// * `hue` - Hue channel. [0.0, 360.0]
46    /// * `alpha` - Alpha channel. [0.0, 1.0]
47    pub const fn new(lightness: f32, chroma: f32, hue: f32, alpha: f32) -> Self {
48        Self {
49            lightness,
50            chroma,
51            hue,
52            alpha,
53        }
54    }
55
56    /// Construct a new [`Lcha`] color from (h, s, l) components, with the default alpha (1.0).
57    ///
58    /// # Arguments
59    ///
60    /// * `lightness` - Lightness channel. [0.0, 1.5]
61    /// * `chroma` - Chroma channel. Typically [0.0, 1.5]
62    /// * `hue` - Hue channel. [0.0, 360.0]
63    pub const fn lch(lightness: f32, chroma: f32, hue: f32) -> Self {
64        Self {
65            lightness,
66            chroma,
67            hue,
68            alpha: 1.0,
69        }
70    }
71
72    /// Return a copy of this color with the chroma channel set to the given value.
73    pub const fn with_chroma(self, chroma: f32) -> Self {
74        Self { chroma, ..self }
75    }
76
77    /// Return a copy of this color with the lightness channel set to the given value.
78    pub const fn with_lightness(self, lightness: f32) -> Self {
79        Self { lightness, ..self }
80    }
81
82    /// Generate a deterministic but [quasi-randomly distributed](https://en.wikipedia.org/wiki/Low-discrepancy_sequence)
83    /// color from a provided `index`.
84    ///
85    /// This can be helpful for generating debug colors.
86    ///
87    /// # Examples
88    ///
89    /// ```rust
90    /// # use bevy_color::Lcha;
91    /// // Unique color for an entity
92    /// # let entity_index = 123;
93    /// // let entity_index = entity.index();
94    /// let color = Lcha::sequential_dispersed(entity_index);
95    ///
96    /// // Palette with 5 distinct hues
97    /// let palette = (0..5).map(Lcha::sequential_dispersed).collect::<Vec<_>>();
98    /// ```
99    pub const fn sequential_dispersed(index: u32) -> Self {
100        const FRAC_U32MAX_GOLDEN_RATIO: u32 = 2654435769; // (u32::MAX / Φ) rounded up
101        const RATIO_360: f32 = 360.0 / u32::MAX as f32;
102
103        // from https://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/
104        //
105        // Map a sequence of integers (eg: 154, 155, 156, 157, 158) into the [0.0..1.0] range,
106        // so that the closer the numbers are, the larger the difference of their image.
107        let hue = index.wrapping_mul(FRAC_U32MAX_GOLDEN_RATIO) as f32 * RATIO_360;
108        Self::lch(0.75, 0.35, hue)
109    }
110}
111
112impl Default for Lcha {
113    fn default() -> Self {
114        Self::new(1., 0., 0., 1.)
115    }
116}
117
118impl Mix for Lcha {
119    #[inline]
120    fn mix(&self, other: &Self, factor: f32) -> Self {
121        let n_factor = 1.0 - factor;
122        Self {
123            lightness: self.lightness * n_factor + other.lightness * factor,
124            chroma: self.chroma * n_factor + other.chroma * factor,
125            hue: crate::color_ops::lerp_hue(self.hue, other.hue, factor),
126            alpha: self.alpha * n_factor + other.alpha * factor,
127        }
128    }
129}
130
131impl Gray for Lcha {
132    const BLACK: Self = Self::new(0.0, 0.0, 0.0000136603785, 1.0);
133    const WHITE: Self = Self::new(1.0, 0.0, 0.0000136603785, 1.0);
134}
135
136impl Alpha for Lcha {
137    #[inline]
138    fn with_alpha(&self, alpha: f32) -> Self {
139        Self { alpha, ..*self }
140    }
141
142    #[inline]
143    fn alpha(&self) -> f32 {
144        self.alpha
145    }
146
147    #[inline]
148    fn set_alpha(&mut self, alpha: f32) {
149        self.alpha = alpha;
150    }
151}
152
153impl Hue for Lcha {
154    #[inline]
155    fn with_hue(&self, hue: f32) -> Self {
156        Self { hue, ..*self }
157    }
158
159    #[inline]
160    fn hue(&self) -> f32 {
161        self.hue
162    }
163
164    #[inline]
165    fn set_hue(&mut self, hue: f32) {
166        self.hue = hue;
167    }
168}
169
170impl Luminance for Lcha {
171    #[inline]
172    fn with_luminance(&self, lightness: f32) -> Self {
173        Self { lightness, ..*self }
174    }
175
176    fn luminance(&self) -> f32 {
177        self.lightness
178    }
179
180    fn darker(&self, amount: f32) -> Self {
181        Self::new(
182            (self.lightness - amount).max(0.),
183            self.chroma,
184            self.hue,
185            self.alpha,
186        )
187    }
188
189    fn lighter(&self, amount: f32) -> Self {
190        Self::new(
191            (self.lightness + amount).min(1.),
192            self.chroma,
193            self.hue,
194            self.alpha,
195        )
196    }
197}
198
199impl ColorToComponents for Lcha {
200    fn to_f32_array(self) -> [f32; 4] {
201        [self.lightness, self.chroma, self.hue, self.alpha]
202    }
203
204    fn to_f32_array_no_alpha(self) -> [f32; 3] {
205        [self.lightness, self.chroma, self.hue]
206    }
207
208    fn to_vec4(self) -> Vec4 {
209        Vec4::new(self.lightness, self.chroma, self.hue, self.alpha)
210    }
211
212    fn to_vec3(self) -> Vec3 {
213        Vec3::new(self.lightness, self.chroma, self.hue)
214    }
215
216    fn from_f32_array(color: [f32; 4]) -> Self {
217        Self {
218            lightness: color[0],
219            chroma: color[1],
220            hue: color[2],
221            alpha: color[3],
222        }
223    }
224
225    fn from_f32_array_no_alpha(color: [f32; 3]) -> Self {
226        Self {
227            lightness: color[0],
228            chroma: color[1],
229            hue: color[2],
230            alpha: 1.0,
231        }
232    }
233
234    fn from_vec4(color: Vec4) -> Self {
235        Self {
236            lightness: color[0],
237            chroma: color[1],
238            hue: color[2],
239            alpha: color[3],
240        }
241    }
242
243    fn from_vec3(color: Vec3) -> Self {
244        Self {
245            lightness: color[0],
246            chroma: color[1],
247            hue: color[2],
248            alpha: 1.0,
249        }
250    }
251}
252
253impl From<Lcha> for Laba {
254    fn from(
255        Lcha {
256            lightness,
257            chroma,
258            hue,
259            alpha,
260        }: Lcha,
261    ) -> Self {
262        // Based on http://www.brucelindbloom.com/index.html?Eqn_LCH_to_Lab.html
263        let l = lightness;
264        let (sin, cos) = ops::sin_cos(hue.to_radians());
265        let a = chroma * cos;
266        let b = chroma * sin;
267
268        Laba::new(l, a, b, alpha)
269    }
270}
271
272impl From<Laba> for Lcha {
273    fn from(
274        Laba {
275            lightness,
276            a,
277            b,
278            alpha,
279        }: Laba,
280    ) -> Self {
281        // Based on http://www.brucelindbloom.com/index.html?Eqn_Lab_to_LCH.html
282        let chroma = ops::hypot(a, b);
283        let hue = ops::atan2(b, a).to_degrees();
284        let hue = if hue < 0.0 { hue + 360.0 } else { hue };
285
286        Lcha::new(lightness, chroma, hue, alpha)
287    }
288}
289
290// Derived Conversions
291
292impl From<Srgba> for Lcha {
293    fn from(value: Srgba) -> Self {
294        Laba::from(value).into()
295    }
296}
297
298impl From<Lcha> for Srgba {
299    fn from(value: Lcha) -> Self {
300        Laba::from(value).into()
301    }
302}
303
304impl From<LinearRgba> for Lcha {
305    fn from(value: LinearRgba) -> Self {
306        Laba::from(value).into()
307    }
308}
309
310impl From<Lcha> for LinearRgba {
311    fn from(value: Lcha) -> Self {
312        Laba::from(value).into()
313    }
314}
315
316impl From<Xyza> for Lcha {
317    fn from(value: Xyza) -> Self {
318        Laba::from(value).into()
319    }
320}
321
322impl From<Lcha> for Xyza {
323    fn from(value: Lcha) -> Self {
324        Laba::from(value).into()
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::{
332        color_difference::EuclideanDistance, test_colors::TEST_COLORS, testing::assert_approx_eq,
333    };
334
335    #[test]
336    fn test_to_from_srgba() {
337        for color in TEST_COLORS.iter() {
338            let rgb2: Srgba = (color.lch).into();
339            let lcha: Lcha = (color.rgb).into();
340            assert!(
341                color.rgb.distance(&rgb2) < 0.0001,
342                "{}: {:?} != {:?}",
343                color.name,
344                color.rgb,
345                rgb2
346            );
347            assert_approx_eq!(color.lch.lightness, lcha.lightness, 0.001);
348            if lcha.lightness > 0.01 {
349                assert_approx_eq!(color.lch.chroma, lcha.chroma, 0.1);
350            }
351            if lcha.lightness > 0.01 && lcha.chroma > 0.01 {
352                assert!(
353                    (color.lch.hue - lcha.hue).abs() < 1.7,
354                    "{:?} != {:?}",
355                    color.lch,
356                    lcha
357                );
358            }
359            assert_approx_eq!(color.lch.alpha, lcha.alpha, 0.001);
360        }
361    }
362
363    #[test]
364    fn wide_gamut_chroma_preserved() {
365        let laba = Laba::new(0.8, 1.5, -1.2, 1.0);
366        let lcha: Lcha = laba.into();
367        assert!(lcha.chroma > 1.9, "chroma was clamped: {:?}", lcha);
368
369        let back: Laba = lcha.into();
370        assert_approx_eq!(laba.lightness, back.lightness, 1e-4);
371        assert_approx_eq!(laba.a, back.a, 1e-4);
372        assert_approx_eq!(laba.b, back.b, 1e-4);
373    }
374
375    #[test]
376    fn test_to_from_linear() {
377        for color in TEST_COLORS.iter() {
378            let rgb2: LinearRgba = (color.lch).into();
379            let lcha: Lcha = (color.linear_rgb).into();
380            assert!(
381                color.linear_rgb.distance(&rgb2) < 0.0001,
382                "{}: {:?} != {:?}",
383                color.name,
384                color.linear_rgb,
385                rgb2
386            );
387            assert_approx_eq!(color.lch.lightness, lcha.lightness, 0.001);
388            if lcha.lightness > 0.01 {
389                assert_approx_eq!(color.lch.chroma, lcha.chroma, 0.1);
390            }
391            if lcha.lightness > 0.01 && lcha.chroma > 0.01 {
392                assert!(
393                    (color.lch.hue - lcha.hue).abs() < 1.7,
394                    "{:?} != {:?}",
395                    color.lch,
396                    lcha
397                );
398            }
399            assert_approx_eq!(color.lch.alpha, lcha.alpha, 0.001);
400        }
401    }
402}