optic_color/gradient.rs
1use crate::convert::{hsv_to_rgba, rgba_to_hsv};
2use crate::{channel_lerp, HSV, RGBA, ToRgba};
3
4/// A single control point in a gradient.
5#[derive(Copy, Clone, Debug)]
6pub struct GradientStop {
7 /// Position on the 0..1 gradient axis.
8 pub position: f32,
9 /// Color at this position.
10 pub color: RGBA,
11}
12
13/// Interpolation mode between gradient stops.
14#[derive(Copy, Clone, Debug)]
15pub enum GradientInterp {
16 /// Linear blend between stops (default).
17 Linear,
18 /// No interpolation — output is the color of the nearest stop on the left.
19 Step,
20 /// Smooth Hermite interpolation (`t²(3-2t)`).
21 SmoothStep,
22}
23
24/// Color space used for interpolation between stops.
25#[derive(Copy, Clone, Debug)]
26pub enum GradientColorSpace {
27 /// Interpolate in RGB space (direct channel lerp).
28 ///
29 /// Fast but can produce muddy intermediate colors.
30 Rgb,
31 /// Interpolate in HSV space with hue-aware shortest-path blending.
32 ///
33 /// Produces rainbow-like transitions. More expensive but visually
34 /// pleasing for color ramps.
35 Hsv,
36}
37
38/// Wrap mode for positions outside 0..1.
39#[derive(Copy, Clone, Debug)]
40pub enum GradientWrap {
41 /// Clamp to 0..1 (default).
42 Clamp,
43 /// Repeat the gradient (modular).
44 Repeat,
45 /// Mirror back and forth.
46 PingPong,
47}
48
49/// A configurable color gradient evaluator.
50///
51/// `Gradient` maps a normalized position `t` (0..1) to an [`RGBA`] color
52/// by interpolating between control points (stops). It supports multiple
53/// interpolation modes, color spaces, and wrap modes.
54///
55/// # Construction
56///
57/// ```
58/// use optic_color::*;
59///
60/// // Manually
61/// let mut g = Gradient::new();
62/// g.add_stop(0.0, RED);
63/// g.add_stop(1.0, BLUE);
64///
65/// // Convenience
66/// let g = Gradient::two_color(RED, BLUE);
67/// let g = Gradient::rainbow();
68/// ```
69///
70/// # Sampling
71///
72/// ```
73/// use optic_color::*;
74///
75/// let g = Gradient::two_color(BLACK, WHITE);
76/// assert_eq!(g.sample(0.0), BLACK);
77/// assert_eq!(g.sample(1.0), WHITE);
78///
79/// let colors = g.sample_n(5); // 5 evenly-spaced colors
80/// ```
81///
82/// # Configuration
83///
84/// ```
85/// use optic_color::*;
86///
87/// let g = Gradient::two_color(RED, BLUE)
88/// .set_color_space(GradientColorSpace::Hsv)
89/// .set_interp(GradientInterp::SmoothStep)
90/// .set_wrap(GradientWrap::PingPong);
91/// ```
92///
93/// # Presets
94///
95/// * [`fire`](Gradient::fire) — black → red → orange → yellow → white
96/// * [`rainbow`](Gradient::rainbow) — full HSV hue sweep
97/// * [`grayscale`](Gradient::grayscale) — black → white
98pub struct Gradient {
99 stops: Vec<GradientStop>,
100 interp: GradientInterp,
101 color_space: GradientColorSpace,
102 wrap: GradientWrap,
103}
104
105impl Gradient {
106 /// Create an empty gradient.
107 ///
108 /// Defaults: linear RGB interpolation, clamp wrap mode.
109 /// Sampling an empty gradient returns `RGBA(0, 0, 0, 0)`.
110 pub fn new() -> Self {
111 Gradient {
112 stops: Vec::new(),
113 interp: GradientInterp::Linear,
114 color_space: GradientColorSpace::Rgb,
115 wrap: GradientWrap::Clamp,
116 }
117 }
118
119 /// Add a stop at `position` (0..1) with the given color.
120 ///
121 /// Stops are kept sorted by position. If a stop already exists at the
122 /// same position, the new stop is inserted after it.
123 ///
124 /// Returns `&mut self` for chaining.
125 pub fn add_stop(&mut self, position: f32, color: impl ToRgba) -> &mut Self {
126 let pos = position.clamp(0.0, 1.0);
127 let stop = GradientStop { position: pos, color: color.to_rgba() };
128 let idx = self.stops.binary_search_by(|s| s.position.partial_cmp(&pos).unwrap()).unwrap_or_else(|e| e);
129 self.stops.insert(idx, stop);
130 self
131 }
132
133 /// Remove the stop at `index`.
134 ///
135 /// Does nothing if `index` is out of bounds.
136 pub fn remove_stop(&mut self, index: usize) {
137 if index < self.stops.len() {
138 self.stops.remove(index);
139 }
140 }
141
142 /// Returns all stops as a slice.
143 pub fn stops(&self) -> &[GradientStop] { &self.stops }
144
145 /// Remove all stops.
146 pub fn clear(&mut self) { self.stops.clear(); }
147
148 /// Sample the gradient at position `t`.
149 ///
150 /// The effective position is determined by the current [`GradientWrap`]
151 /// mode before lookup. Returns the color of the nearest stop if `t`
152 /// falls outside the stop range after wrapping/clamping.
153 ///
154 /// If the gradient has no stops, returns transparent black.
155 /// If the gradient has exactly one stop, returns that color for any `t`.
156 pub fn sample(&self, t: f32) -> RGBA {
157 if self.stops.is_empty() {
158 return RGBA(0.0, 0.0, 0.0, 0.0);
159 }
160 if self.stops.len() == 1 {
161 return self.stops[0].color;
162 }
163 let t = match self.wrap {
164 GradientWrap::Clamp => t.clamp(0.0, 1.0),
165 GradientWrap::Repeat => t - t.floor(),
166 GradientWrap::PingPong => {
167 let r#mod = t - t.floor();
168 if t.floor() as i32 % 2 == 0 { r#mod } else { 1.0 - r#mod }
169 }
170 };
171 let t = t.clamp(0.0, 1.0);
172 let i = match self.stops.binary_search_by(|s| s.position.partial_cmp(&t).unwrap()) {
173 Ok(i) => i,
174 Err(i) => {
175 if i == 0 { return self.stops[0].color; }
176 if i >= self.stops.len() { return self.stops[self.stops.len() - 1].color; }
177 i - 1
178 }
179 };
180 let (a, b) = if i + 1 < self.stops.len() {
181 (self.stops[i], self.stops[i + 1])
182 } else {
183 return self.stops[i].color;
184 };
185 if a.position == b.position {
186 return b.color;
187 }
188 let local_t = (t - a.position) / (b.position - a.position);
189 let local_t = match self.interp {
190 GradientInterp::Linear => local_t,
191 GradientInterp::Step => 0.0,
192 GradientInterp::SmoothStep => local_t * local_t * (3.0 - 2.0 * local_t),
193 };
194 match self.color_space {
195 GradientColorSpace::Rgb => {
196 channel_lerp(a.color, b.color, local_t)
197 }
198 GradientColorSpace::Hsv => {
199 let hsv_a = rgba_to_hsv(a.color);
200 let hsv_b = rgba_to_hsv(b.color);
201 let h = hue_lerp(hsv_a.h, hsv_b.h, local_t);
202 let s = hsv_a.s + (hsv_b.s - hsv_a.s) * local_t;
203 let v = hsv_a.v + (hsv_b.v - hsv_a.v) * local_t;
204 hsv_to_rgba(HSV { h, s, v }).with_alpha(
205 a.color.3 + (b.color.3 - a.color.3) * local_t,
206 )
207 }
208 }
209 }
210
211 /// Sample `count` evenly-spaced colors across the 0..1 range.
212 ///
213 /// The first sample is at `t=0`, the last at `t=1`.
214 /// Returns an empty vec if `count == 0`.
215 pub fn sample_n(&self, count: usize) -> Vec<RGBA> {
216 if count == 0 { return Vec::new(); }
217 let mut out = Vec::with_capacity(count);
218 for i in 0..count {
219 let t = i as f32 / (count - 1) as f32;
220 out.push(self.sample(t));
221 }
222 out
223 }
224
225 /// Set the interpolation mode.
226 ///
227 /// Returns `&mut self` for chaining.
228 pub fn set_interp(&mut self, mode: GradientInterp) -> &mut Self {
229 self.interp = mode;
230 self
231 }
232
233 /// Set the color space used for interpolation.
234 ///
235 /// Returns `&mut self` for chaining.
236 pub fn set_color_space(&mut self, space: GradientColorSpace) -> &mut Self {
237 self.color_space = space;
238 self
239 }
240
241 /// Set the wrap mode.
242 ///
243 /// Returns `&mut self` for chaining.
244 pub fn set_wrap(&mut self, wrap: GradientWrap) -> &mut Self {
245 self.wrap = wrap;
246 self
247 }
248
249 /// Reverse the stop order (mirrors the gradient).
250 ///
251 /// Returns `&mut self` for chaining.
252 pub fn reverse(&mut self) -> &mut Self {
253 self.stops.reverse();
254 for s in &mut self.stops {
255 s.position = 1.0 - s.position;
256 }
257 self.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap());
258 self
259 }
260
261 /// Construct a gradient from evenly-spaced colors.
262 ///
263 /// Each color is placed at `i / (len-1)` along the 0..1 axis.
264 /// If the input slice is empty, returns an empty gradient.
265 pub fn from_colors(colors: &[impl ToRgba]) -> Self {
266 let mut g = Gradient::new();
267 let count = colors.len();
268 if count == 0 { return g; }
269 for (i, c) in colors.iter().enumerate() {
270 let t = if count == 1 { 0.0 } else { i as f32 / (count - 1) as f32 };
271 g.add_stop(t, *c);
272 }
273 g
274 }
275
276 /// Construct a two-stop gradient.
277 pub fn two_color(a: impl ToRgba, b: impl ToRgba) -> Self {
278 let mut g = Gradient::new();
279 g.add_stop(0.0, a);
280 g.add_stop(1.0, b);
281 g
282 }
283
284 /// A full HSV rainbow sweep (red → red via 360°).
285 pub fn rainbow() -> Self {
286 let mut g = Gradient::new();
287 g.set_color_space(GradientColorSpace::Hsv);
288 g.add_stop(0.0, HSV::new(0.0, 1.0, 1.0));
289 g.add_stop(1.0, HSV::new(360.0, 1.0, 1.0));
290 g
291 }
292
293 /// A fire color ramp (black → red → orange → yellow → white).
294 pub fn fire() -> Self {
295 let mut g = Gradient::new();
296 g.add_stop(0.0, crate::BLACK);
297 g.add_stop(0.25, crate::RED);
298 g.add_stop(0.5, crate::ORANGE);
299 g.add_stop(0.75, crate::YELLOW);
300 g.add_stop(1.0, crate::WHITE);
301 g
302 }
303
304 /// A greyscale ramp (black → white).
305 pub fn grayscale() -> Self {
306 let mut g = Gradient::new();
307 g.add_stop(0.0, crate::BLACK);
308 g.add_stop(1.0, crate::WHITE);
309 g
310 }
311}
312
313/// Shortest-path hue interpolation on a circle.
314///
315/// Takes the shorter arc between two hue angles (0..360), interpolating
316/// through 0° if necessary.
317fn hue_lerp(a: f32, b: f32, t: f32) -> f32 {
318 let mut diff = b - a;
319 if diff > 180.0 { diff -= 360.0; }
320 else if diff < -180.0 { diff += 360.0; }
321 let result = a + diff * t;
322 if result < 0.0 { result + 360.0 }
323 else if result >= 360.0 { result - 360.0 }
324 else { result }
325}