Skip to main content

sequoia_draw/
gradient.rs

1use sequoia_core::{Color, Point, Vec2};
2
3#[derive(Clone, Copy, Debug, PartialEq)]
4pub struct GradientStop {
5    pub at: f32,
6
7    pub color: Color,
8}
9
10impl GradientStop {
11    pub fn new(at: f32, color: Color) -> Self {
12        Self { at, color }
13    }
14}
15
16#[derive(Clone, Debug, PartialEq)]
17#[non_exhaustive]
18pub enum Gradient {
19    Linear {
20        from: Point,
21
22        to: Point,
23
24        stops: Vec<GradientStop>,
25    },
26
27    Radial {
28        center: Point,
29
30        radius: f32,
31
32        stops: Vec<GradientStop>,
33    },
34}
35
36impl Gradient {
37    pub fn linear(
38        from: impl Into<Point>,
39        to: impl Into<Point>,
40        stops: impl Into<Vec<GradientStop>>,
41    ) -> Self {
42        Self::Linear {
43            from: from.into(),
44            to: to.into(),
45            stops: sorted(stops.into()),
46        }
47    }
48
49    pub fn radial(
50        center: impl Into<Point>,
51        radius: f32,
52        stops: impl Into<Vec<GradientStop>>,
53    ) -> Self {
54        Self::Radial {
55            center: center.into(),
56            radius,
57            stops: sorted(stops.into()),
58        }
59    }
60
61    pub fn stops(&self) -> &[GradientStop] {
62        match self {
63            Self::Linear { stops, .. } | Self::Radial { stops, .. } => stops,
64        }
65    }
66
67    pub fn is_vertical(&self) -> bool {
68        match self {
69            Self::Linear { from, to, .. } => {
70                let d = Vec2::new(to.x - from.x, to.y - from.y);
71                d.y.abs() > d.x.abs()
72            }
73            Self::Radial { .. } => false,
74        }
75    }
76
77    pub fn direction(&self) -> Vec2 {
78        match self {
79            Self::Linear { from, to, .. } => Vec2::new(to.x - from.x, to.y - from.y),
80            Self::Radial { .. } => Vec2::ZERO,
81        }
82    }
83
84    pub fn color_at(&self, p: Point) -> Color {
85        let t = match self {
86            Self::Linear { from, to, .. } => {
87                let d = Vec2::new(to.x - from.x, to.y - from.y);
88                let len2 = d.x * d.x + d.y * d.y;
89                if len2 <= f32::MIN_POSITIVE {
90                    0.0
91                } else {
92                    ((p.x - from.x) * d.x + (p.y - from.y) * d.y) / len2
93                }
94            }
95            Self::Radial { center, radius, .. } => {
96                if !(*radius > 0.0) || !radius.is_finite() {
97                    0.0
98                } else {
99                    let d = Vec2::new(p.x - center.x, p.y - center.y);
100                    (d.x * d.x + d.y * d.y).sqrt() / *radius
101                }
102            }
103        };
104        sample_stops(t, self.stops())
105    }
106}
107
108fn sorted(mut stops: Vec<GradientStop>) -> Vec<GradientStop> {
109    stops.sort_by(|a, b| a.at.partial_cmp(&b.at).unwrap_or(std::cmp::Ordering::Equal));
110    stops
111}
112
113fn sample_stops(t: f32, stops: &[GradientStop]) -> Color {
114    let Some(first) = stops.first() else {
115        return Color::TRANSPARENT;
116    };
117    if t <= first.at || stops.len() == 1 {
118        return first.color;
119    }
120    let last = stops[stops.len() - 1];
121    if t >= last.at {
122        return last.color;
123    }
124    for pair in stops.windows(2) {
125        let (a, b) = (pair[0], pair[1]);
126        if t <= b.at {
127            let span = b.at - a.at;
128            let k = if span <= f32::MIN_POSITIVE {
129                0.0
130            } else {
131                (t - a.at) / span
132            };
133            return lerp_color(a.color, b.color, k);
134        }
135    }
136    last.color
137}
138
139fn lerp_color(a: Color, b: Color, k: f32) -> Color {
140    let k = k.clamp(0.0, 1.0);
141    let mix = |x: f32, y: f32| x + (y - x) * k;
142    Color {
143        r: mix(a.r, b.r),
144        g: mix(a.g, b.g),
145        b: mix(a.b, b.b),
146        a: mix(a.a, b.a),
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn red() -> Color {
155        Color::rgb8(255, 0, 0)
156    }
157
158    fn blue() -> Color {
159        Color::rgb8(0, 0, 255)
160    }
161
162    #[test]
163    fn linear_gradient_runs_along_the_axis() {
164        let g = Gradient::linear(
165            (0.0, 0.0),
166            (100.0, 0.0),
167            vec![
168                GradientStop::new(0.0, red()),
169                GradientStop::new(1.0, blue()),
170            ],
171        );
172        assert_eq!(g.color_at(Point::new(0.0, 50.0)), red());
173        assert_eq!(g.color_at(Point::new(100.0, 50.0)), blue());
174        let mid = g.color_at(Point::new(50.0, 0.0));
175        assert!(
176            (mid.r - 0.5).abs() < 1e-6 && (mid.b - 0.5).abs() < 1e-6,
177            "{mid:?}"
178        );
179
180        assert_eq!(g.color_at(Point::new(-40.0, 0.0)), red());
181        assert_eq!(g.color_at(Point::new(400.0, 0.0)), blue());
182        assert!(!g.is_vertical());
183    }
184
185    #[test]
186    fn vertical_linear_gradient_is_detected() {
187        let g = Gradient::linear(
188            (0.0, 0.0),
189            (0.0, 100.0),
190            vec![
191                GradientStop::new(0.0, red()),
192                GradientStop::new(1.0, blue()),
193            ],
194        );
195        assert!(g.is_vertical());
196        let quarter = g.color_at(Point::new(80.0, 25.0));
197        assert!((quarter.b - 0.25).abs() < 1e-6, "{quarter:?}");
198    }
199
200    #[test]
201    fn radial_gradient_uses_distance() {
202        let g = Gradient::radial(
203            (50.0, 50.0),
204            40.0,
205            vec![
206                GradientStop::new(0.0, red()),
207                GradientStop::new(1.0, blue()),
208            ],
209        );
210        assert_eq!(g.color_at(Point::new(50.0, 50.0)), red());
211        assert_eq!(g.color_at(Point::new(90.0, 50.0)), blue());
212        assert!(!g.is_vertical());
213        let mid = g.color_at(Point::new(50.0, 70.0));
214        assert!((mid.b - 0.5).abs() < 1e-6, "{mid:?}");
215
216        let dot = Gradient::radial((0.0, 0.0), 0.0, vec![GradientStop::new(0.0, red())]);
217        assert_eq!(dot.color_at(Point::new(10.0, 10.0)), red());
218    }
219
220    #[test]
221    fn stops_are_sorted_and_three_stops_interpolate() {
222        let g = Gradient::linear(
223            (0.0, 0.0),
224            (100.0, 0.0),
225            vec![
226                GradientStop::new(1.0, blue()),
227                GradientStop::new(0.0, red()),
228                GradientStop::new(0.5, Color::rgb8(0, 255, 0)),
229            ],
230        );
231        let at = g.stops().iter().map(|s| s.at).collect::<Vec<_>>();
232        assert_eq!(at, vec![0.0, 0.5, 1.0], "input order does not matter");
233        let mid = g.color_at(Point::new(25.0, 0.0));
234        assert!(
235            (mid.g - 0.5).abs() < 1e-6 && (mid.r - 0.5).abs() < 1e-6,
236            "{mid:?}"
237        );
238    }
239
240    #[test]
241    fn empty_gradient_is_transparent() {
242        let g = Gradient::linear((0.0, 0.0), (10.0, 0.0), Vec::new());
243        assert_eq!(g.color_at(Point::new(5.0, 0.0)), Color::TRANSPARENT);
244    }
245
246    #[test]
247    fn alpha_interpolates_too() {
248        let g = Gradient::linear(
249            (0.0, 0.0),
250            (10.0, 0.0),
251            vec![
252                GradientStop::new(0.0, Color { a: 0.0, ..red() }),
253                GradientStop::new(1.0, Color { a: 1.0, ..red() }),
254            ],
255        );
256        assert!((g.color_at(Point::new(5.0, 0.0)).a - 0.5).abs() < 1e-6);
257    }
258}