Skip to main content

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}