blinksy/color/
hsv.rs

1use core::marker::PhantomData;
2
3#[allow(unused_imports)]
4use num_traits::float::FloatCore;
5#[allow(unused_imports)]
6use num_traits::Euclid;
7
8use super::{FromColor, LinearSrgb};
9
10/// HSV color model (Hue, Saturation, Value)
11///
12/// HSV is a color model that separates color into:
13///
14/// - Hue: The color type (red, green, blue, etc.)
15/// - Saturation: The purity of the color (0.0 = grayscale, 1.0 = pure color)
16/// - Value: The brightness of the color (0.0 = black, 1.0 = maximum brightness)
17///
18/// Inspired by [FastLED's HSV], [`Hsv`] receives a generic `M` which implements [`HsvHueMap`], so
19/// you can control how a hue is mapped to a color. The default mapping [`HsvHueRainbow`] provides
20/// more evenly-spaced color bands, including enhanced yellow and deep purple bands.
21///
22/// [FastLED's HSV]: https://github.com/FastLED/FastLED/wiki/FastLED-HSV-Colors
23#[derive(Debug, Clone, Copy, PartialEq)]
24#[cfg_attr(feature = "defmt", derive(defmt::Format))]
25pub struct Hsv<M: HsvHueMap = HsvHueRainbow> {
26    /// HsvHue component
27    pub hue: HsvHue<M>,
28    /// Saturation component (0.0 to 1.0)
29    pub saturation: f32,
30    /// Value component (0.0 to 1.0)
31    pub value: f32,
32}
33
34impl<M: HsvHueMap> Hsv<M> {
35    /// Creates a new HSV color
36    ///
37    /// # Arguments
38    ///
39    /// - `hue` - Hue component (0.0 to 1.0)
40    /// - `saturation` - Saturation component (0.0 to 1.0)
41    /// - `value` - Value component (0.0 to 1.0)
42    pub fn new(hue: f32, saturation: f32, value: f32) -> Self {
43        Self {
44            hue: HsvHue::new(hue),
45            saturation: saturation.clamp(0.0, 1.0),
46            value: value.clamp(0.0, 1.0),
47        }
48    }
49
50    /// Creates a new HSV color from an existing HsvHue object
51    ///
52    /// # Arguments
53    ///
54    /// - `hue` - Existing HsvHue object
55    /// - `saturation` - Saturation component (0.0 to 1.0)
56    /// - `value` - Value component (0.0 to 1.0)
57    pub fn from_hue(hue: HsvHue<M>, saturation: f32, value: f32) -> Self {
58        Self {
59            hue,
60            saturation: saturation.clamp(0.0, 1.0),
61            value: value.clamp(0.0, 1.0),
62        }
63    }
64}
65
66impl<M: HsvHueMap> FromColor<Hsv<M>> for LinearSrgb {
67    fn from_color(color: Hsv<M>) -> Self {
68        // Special case for zero saturation (grayscale)
69        if color.saturation <= 0.0 {
70            let v = color.value;
71            return LinearSrgb::new(v, v, v);
72        }
73
74        // Special case for zero value (black)
75        if color.value <= 0.0 {
76            return LinearSrgb::new(0.0, 0.0, 0.0);
77        }
78
79        // Get the pure hue color
80        let rgb = color.hue.to_rgb();
81
82        // If fully saturated, just scale by value
83        if color.saturation >= 1.0 {
84            return LinearSrgb::new(
85                rgb.red * color.value,
86                rgb.green * color.value,
87                rgb.blue * color.value,
88            );
89        }
90
91        // For partial saturation, blend with gray
92        let gray = color.value;
93        let s = color.saturation;
94
95        LinearSrgb::new(
96            rgb.red * s * color.value + gray * (1.0 - s),
97            rgb.green * s * color.value + gray * (1.0 - s),
98            rgb.blue * s * color.value + gray * (1.0 - s),
99        )
100    }
101}
102
103/// Representation of a color hue with a specific mapping method
104///
105/// The [`HsvHue`] type represents a position on the color wheel using a mapping
106/// method (M) to convert between hue values and colors.
107///
108/// Different hue maps produce different color distributions when rotating
109/// through the entire hue range. See [FastLED's HSV].
110///
111/// [FastLED's HSV]: https://github.com/FastLED/FastLED/wiki/FastLED-HSV-Colors
112#[derive(Debug, Clone, Copy, PartialEq)]
113#[cfg_attr(feature = "defmt", derive(defmt::Format))]
114pub struct HsvHue<M: HsvHueMap = HsvHueRainbow> {
115    /// Phantom data to track the hue mapping type
116    map: PhantomData<M>,
117    /// HsvHue value (0.0 to 1.0)
118    inner: f32,
119}
120
121impl<M: HsvHueMap> HsvHue<M> {
122    /// Creates a new hue value
123    ///
124    /// # Arguments
125    ///
126    /// - `hue` - HsvHue value (0.0 to 1.0)
127    pub fn new(hue: f32) -> Self {
128        Self {
129            map: PhantomData,
130            inner: Euclid::rem_euclid(&hue, &1.0),
131        }
132    }
133
134    /// Returns the raw hue value (0.0 to 1.0)
135    pub fn inner(self) -> f32 {
136        self.inner
137    }
138
139    /// Converts the hue to RGB using the specified mapping method
140    pub fn to_rgb(&self) -> LinearSrgb {
141        M::hue_to_rgb(self.inner)
142    }
143}
144
145/// Trait for hue mapping algorithms, inspired by [FastLED's HSV].
146///
147/// A hue map defines how a numerical hue value (0.0 to 1.0) is converted
148/// to RGB colors. Different mapping approaches produce different color
149/// distributions when rotating through the entire hue range.
150///
151/// [FastLED's HSV]: https://github.com/FastLED/FastLED/wiki/FastLED-HSV-Colors
152///
153/// ## Implementators
154///
155/// - [`HsvHueRainbow`]: Visually balanced rainbow
156/// - [`HsvHueSpectrum`]: Mathematically straight spectrum
157///
158pub trait HsvHueMap: Sized {
159    /// Convert a hue value to RGB
160    ///
161    /// # Arguments
162    ///
163    /// - `hue` - HsvHue value (0.0 to 1.0)
164    ///
165    /// # Returns
166    ///
167    /// A LinearSrgb color representing the hue
168    fn hue_to_rgb(hue: f32) -> LinearSrgb;
169}
170
171/// Spectrum hue mapping as used in FastLED's hsv2rgb_spectrum
172///
173/// This hue mapping produces a mathematically straight spectrum with
174/// equal distribution of hues. It has wide red, green and blue bands, with
175/// a narrow and muddy yellow band.
176///
177/// ![Spectrum hue mapping](https://raw.githubusercontent.com/FastLED/FastLED/gh-pages/images/HSV-spectrum-with-desc.jpg)
178#[derive(Debug, Clone, Copy, PartialEq)]
179#[cfg_attr(feature = "defmt", derive(defmt::Format))]
180pub struct HsvHueSpectrum;
181
182impl HsvHueMap for HsvHueSpectrum {
183    fn hue_to_rgb(hue: f32) -> LinearSrgb {
184        let h = hue * 3.0; // Scale to 0-3 range
185        let section = h.floor() as u8; // Which section: 0, 1, or 2
186        let offset = h - h.floor(); // Position within section (0.0-1.0)
187
188        // Calculate rising and falling values
189        let rise = offset;
190        let fall = 1.0 - offset;
191
192        // Map to RGB based on section
193        match section % 3 {
194            0 => LinearSrgb::new(fall, rise, 0.0), // Red to Green
195            1 => LinearSrgb::new(0.0, fall, rise), // Green to Blue
196            2 => LinearSrgb::new(rise, 0.0, fall), // Blue to Red
197            _ => unreachable!(),                   // Only for the compiler
198        }
199    }
200}
201
202/// Rainbow hue mapping as used in FastLED's hsv2rgb_rainbow
203///
204/// This hue mapping produces a visually balanced rainbow effect with
205/// enhanced yellow and deep purple.
206///
207/// ![Rainbow hue mapping](https://raw.githubusercontent.com/FastLED/FastLED/gh-pages/images/HSV-rainbow-with-desc.jpg)
208#[derive(Debug, Clone, Copy, PartialEq)]
209#[cfg_attr(feature = "defmt", derive(defmt::Format))]
210pub struct HsvHueRainbow;
211
212impl HsvHueMap for HsvHueRainbow {
213    fn hue_to_rgb(hue: f32) -> LinearSrgb {
214        const FRAC_1_3: f32 = 0.333_333_33_f32;
215        const FRAC_2_3: f32 = 0.666_666_7_f32;
216
217        let h8 = hue * 8.0; // Scale to 0-8 range
218        let section = h8.floor() as u8; // 0-7
219        let pos = h8 - h8.floor(); // 0.0-1.0 position within section
220
221        match section % 8 {
222            0 => {
223                // Red (1,0,0) to Orange (~⅔,⅓,0)
224                LinearSrgb::new(
225                    1.0 - (pos * FRAC_1_3), // R: 1→⅔ (fade to ~⅔)
226                    pos * FRAC_1_3,         // G: 0→⅓ (rise to ~⅓)
227                    0.0,                    // B: 0
228                )
229            }
230            1 => {
231                // Orange (~⅔,⅓,0) to Yellow (~⅔,⅔,0)
232                LinearSrgb::new(
233                    FRAC_2_3,                    // R: stays at ~⅔
234                    FRAC_1_3 + (pos * FRAC_1_3), // G: ⅓→⅔ (⅓→⅔)
235                    0.0,                         // B: 0
236                )
237            }
238            2 => {
239                // Yellow (~⅔,⅔,0) to Green (0,1,0)
240                LinearSrgb::new(
241                    FRAC_2_3 * (1.0 - pos),      // R: ⅔→0 (fade from ⅔ to 0)
242                    FRAC_2_3 + (pos * FRAC_1_3), // G: ⅔→1 (rise from ⅔ to 1)
243                    0.0,                         // B: 0
244                )
245            }
246            3 => {
247                // Green (0,1,0) to Aqua (0,⅔,⅓)
248                LinearSrgb::new(
249                    0.0,                    // R: 0
250                    1.0 - (pos * FRAC_1_3), // G: 1→⅔ (fade from 1 to ⅔)
251                    pos * FRAC_1_3,         // B: 0→⅓ (rise to ⅓)
252                )
253            }
254            4 => {
255                // Aqua (0,⅔,⅓) to Blue (0,0,1)
256                LinearSrgb::new(
257                    0.0,                         // R: 0
258                    FRAC_2_3 * (1.0 - pos),      // G: ⅔→0 (fade from ⅔ to 0)
259                    FRAC_1_3 + (pos * FRAC_2_3), // B: ⅓→1 (rise from ⅓ to 1)
260                )
261            }
262            5 => {
263                // Blue (0,0,1) to Purple (⅓,0,⅔)
264                LinearSrgb::new(
265                    pos * FRAC_1_3,         // R: 0→⅓ (rise to ⅓)
266                    0.0,                    // G: 0
267                    1.0 - (pos * FRAC_1_3), // B: 1→⅔ (fade from 1 to ⅔)
268                )
269            }
270            6 => {
271                // Purple (⅓,0,⅔) to Pink (⅔,0,⅓)
272                LinearSrgb::new(
273                    FRAC_1_3 + (pos * FRAC_1_3), // R: ⅓→⅔ (rise from ⅓ to ⅔)
274                    0.0,                         // G: 0
275                    FRAC_2_3 - (pos * FRAC_1_3), // B: ⅔→⅓ (fade from ⅔ to ⅓)
276                )
277            }
278            7 => {
279                // Pink (⅔,0,⅓) to Red (1,0,0)
280                LinearSrgb::new(
281                    FRAC_2_3 + (pos * FRAC_1_3), // R: ⅔→1 (rise from ⅔ to 1)
282                    0.0,                         // G: 0
283                    FRAC_1_3 * (1.0 - pos),      // B: ⅓→0 (fade from ⅓ to 0)
284                )
285            }
286            _ => unreachable!(), // Only for the compiler
287        }
288    }
289}