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/// 
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/// 
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}