Skip to main content

oxideav_scene/
paint.rs

1//! Typed paint patterns: solid colours, multi-stop gradients.
2//!
3//! [`Paint`] is the unified colour-source type for renderable surfaces
4//! that need richer fills than a single 32-bit RGBA value — gradient
5//! backgrounds, shape fills, text colour gradients. The simpler
6//! `Shape::Rect { fill: u32, .. }` / `Background::Solid(u32)` API stays
7//! in place for the common case; `Paint` is the extension point that
8//! lets a renderer reach for a gradient or a future pattern type
9//! without touching the call site.
10//!
11//! ## Gradient stops
12//!
13//! A [`Stop`] is one colour at one `offset` along the gradient line
14//! (`0.0` = start, `1.0` = end). Stops are stored in a `Vec<Stop>` on
15//! [`Gradient`]; the renderer interpolates between consecutive stops
16//! using linear-RGB lerp (matches `KeyframeValue::Color`'s lerp). For
17//! a two-colour gradient pass `&[Stop::new(0.0, c0), Stop::new(1.0,
18//! c1)]`; for a multi-stop sunset pass as many stops as you like.
19//!
20//! Stops are kept in offset-sorted order by the [`Gradient`]
21//! constructor; lookups are O(N) but N is bounded (gradients with
22//! more than ~10 stops are exotic).
23//!
24//! ## Variants
25//!
26//! - [`Gradient::Linear`] — a `(angle_deg, stops)` pair. `angle_deg`
27//!   is clockwise from 12 o'clock (matches CSS `linear-gradient`).
28//! - [`Gradient::Radial`] — a circular gradient centred at a normalised
29//!   `(cx, cy)` with `radius` in normalised canvas units. Useful for
30//!   spotlights, vignettes, planet-fade-in effects.
31//!
32//! Normalised here means 0..=1 across the canvas's smaller axis for
33//! `radius`, and 0..=1 across each axis for `(cx, cy)`. The renderer
34//! converts to canvas units at composite time.
35
36/// One colour stop along a gradient.
37#[derive(Clone, Copy, Debug, PartialEq)]
38pub struct Stop {
39    /// Position along the gradient, `0.0..=1.0`. `0.0` is the start
40    /// edge of the gradient line, `1.0` is the end edge.
41    pub offset: f32,
42    /// `0xRRGGBBAA` colour value.
43    pub color: u32,
44}
45
46impl Stop {
47    /// Convenience constructor — clamps `offset` into `0.0..=1.0`.
48    pub fn new(offset: f32, color: u32) -> Self {
49        Stop {
50            offset: offset.clamp(0.0, 1.0),
51            color,
52        }
53    }
54}
55
56/// Multi-stop gradient pattern.
57#[non_exhaustive]
58#[derive(Clone, Debug, PartialEq)]
59pub enum Gradient {
60    /// Straight-line gradient. `angle_deg` follows CSS conventions:
61    /// `0°` paints bottom-to-top, `90°` left-to-right, etc. Stops are
62    /// offset-sorted (see [`Gradient::linear`]).
63    Linear { angle_deg: f32, stops: Vec<Stop> },
64    /// Circular gradient. `(cx, cy)` is the centre in normalised
65    /// canvas coordinates (`0.5, 0.5` is dead-centre). `radius` is in
66    /// normalised units of the canvas's smaller axis. Stops are
67    /// offset-sorted.
68    Radial {
69        cx: f32,
70        cy: f32,
71        radius: f32,
72        stops: Vec<Stop>,
73    },
74}
75
76impl Gradient {
77    /// Build a linear gradient. Stops are sorted by offset; duplicates
78    /// at the same offset keep their original relative order
79    /// (`sort_by` is stable).
80    pub fn linear(angle_deg: f32, stops: impl IntoIterator<Item = Stop>) -> Self {
81        let mut s: Vec<Stop> = stops.into_iter().collect();
82        s.sort_by(|a, b| {
83            a.offset
84                .partial_cmp(&b.offset)
85                .unwrap_or(std::cmp::Ordering::Equal)
86        });
87        Gradient::Linear {
88            angle_deg,
89            stops: s,
90        }
91    }
92
93    /// Build a radial gradient. Stops are offset-sorted (see
94    /// [`Gradient::linear`]).
95    pub fn radial(cx: f32, cy: f32, radius: f32, stops: impl IntoIterator<Item = Stop>) -> Self {
96        let mut s: Vec<Stop> = stops.into_iter().collect();
97        s.sort_by(|a, b| {
98            a.offset
99                .partial_cmp(&b.offset)
100                .unwrap_or(std::cmp::Ordering::Equal)
101        });
102        Gradient::Radial {
103            cx,
104            cy,
105            radius,
106            stops: s,
107        }
108    }
109
110    /// Borrow the gradient's stop list.
111    pub fn stops(&self) -> &[Stop] {
112        match self {
113            Gradient::Linear { stops, .. } => stops,
114            Gradient::Radial { stops, .. } => stops,
115        }
116    }
117
118    /// Sample the gradient at a normalised position `t ∈ [0, 1]`
119    /// along the gradient axis (for linear) or along the radius (for
120    /// radial). Out-of-range `t` clamps to the nearest endpoint stop.
121    /// Returns `None` if the gradient has no stops.
122    ///
123    /// Interpolation is linear per channel — the same lerp used by
124    /// [`crate::animation::KeyframeValue::Color`] keyframes — so the
125    /// behaviour is consistent across the data model.
126    pub fn sample(&self, t: f32) -> Option<u32> {
127        let stops = self.stops();
128        if stops.is_empty() {
129            return None;
130        }
131        let t = t.clamp(0.0, 1.0);
132        if t <= stops[0].offset {
133            return Some(stops[0].color);
134        }
135        if t >= stops[stops.len() - 1].offset {
136            return Some(stops[stops.len() - 1].color);
137        }
138        // Find segment: last stop with offset <= t.
139        let mut idx = 0;
140        for (i, s) in stops.iter().enumerate() {
141            if s.offset <= t {
142                idx = i;
143            } else {
144                break;
145            }
146        }
147        let a = &stops[idx];
148        let b = &stops[idx + 1];
149        let span = b.offset - a.offset;
150        let f = if span <= 0.0 {
151            0.0
152        } else {
153            (t - a.offset) / span
154        };
155        Some(lerp_color(a.color, b.color, f))
156    }
157}
158
159/// Unified colour-source type. The renderer treats `Solid` as the
160/// fast path and dispatches `Gradient` through a per-stop rasteriser.
161/// Designed so future pattern variants (image tile, dashed stroke
162/// pattern, mesh) can land as new variants without breaking callers
163/// — hence `#[non_exhaustive]`.
164#[non_exhaustive]
165#[derive(Clone, Debug, PartialEq)]
166pub enum Paint {
167    /// Single colour — `0xRRGGBBAA`.
168    Solid(u32),
169    /// Multi-stop gradient — linear or radial.
170    Gradient(Gradient),
171}
172
173impl Paint {
174    /// Convenience — wrap a `0xRRGGBBAA` colour as a [`Paint::Solid`].
175    pub fn solid(rgba: u32) -> Self {
176        Paint::Solid(rgba)
177    }
178
179    /// Convenience — wrap a [`Gradient`] as a [`Paint::Gradient`].
180    pub fn gradient(g: Gradient) -> Self {
181        Paint::Gradient(g)
182    }
183
184    /// Resolve to a single colour at a gradient axis position `t`.
185    /// For [`Paint::Solid`] returns the colour unchanged regardless
186    /// of `t`. Useful for fallback rasterisers that don't implement
187    /// gradient shading and want to pick a representative colour.
188    pub fn sample(&self, t: f32) -> u32 {
189        match self {
190            Paint::Solid(rgba) => *rgba,
191            Paint::Gradient(g) => g.sample(t).unwrap_or(0),
192        }
193    }
194}
195
196/// Per-channel linear lerp on `0xRRGGBBAA` colours. Mirrors the
197/// (private) `lerp_color` in [`crate::animation`] so the gradient
198/// shader produces bit-identical colours to the animation channel
199/// for the same endpoints.
200fn lerp_color(a: u32, b: u32, t: f32) -> u32 {
201    let ac = [
202        ((a >> 24) & 0xff) as f32,
203        ((a >> 16) & 0xff) as f32,
204        ((a >> 8) & 0xff) as f32,
205        (a & 0xff) as f32,
206    ];
207    let bc = [
208        ((b >> 24) & 0xff) as f32,
209        ((b >> 16) & 0xff) as f32,
210        ((b >> 8) & 0xff) as f32,
211        (b & 0xff) as f32,
212    ];
213    let mut out = 0u32;
214    for i in 0..4 {
215        let v = (ac[i] + (bc[i] - ac[i]) * t).clamp(0.0, 255.0) as u32;
216        out |= v << ((3 - i) * 8);
217    }
218    out
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn stop_clamps_offset() {
227        assert_eq!(Stop::new(-0.5, 0).offset, 0.0);
228        assert_eq!(Stop::new(2.0, 0).offset, 1.0);
229        assert_eq!(Stop::new(0.25, 0).offset, 0.25);
230    }
231
232    #[test]
233    fn linear_constructor_sorts_stops() {
234        let g = Gradient::linear(
235            45.0,
236            vec![
237                Stop::new(1.0, 0xFFFFFFFF),
238                Stop::new(0.0, 0x000000FF),
239                Stop::new(0.5, 0x808080FF),
240            ],
241        );
242        let s = g.stops();
243        assert_eq!(s.len(), 3);
244        assert!(s[0].offset <= s[1].offset);
245        assert!(s[1].offset <= s[2].offset);
246    }
247
248    #[test]
249    fn sample_clamps_to_endpoints() {
250        let g = Gradient::linear(
251            0.0,
252            vec![Stop::new(0.0, 0xFF000000), Stop::new(1.0, 0x00FF0000)],
253        );
254        assert_eq!(g.sample(-1.0), Some(0xFF000000));
255        assert_eq!(g.sample(2.0), Some(0x00FF0000));
256    }
257
258    #[test]
259    fn sample_midpoint_lerps_per_channel() {
260        // Black → white at 0.5 → mid-grey.
261        let g = Gradient::linear(
262            0.0,
263            vec![Stop::new(0.0, 0x000000FF), Stop::new(1.0, 0xFFFFFFFF)],
264        );
265        let mid = g.sample(0.5).unwrap();
266        let r = (mid >> 24) & 0xff;
267        let g_ = (mid >> 16) & 0xff;
268        let b = (mid >> 8) & 0xff;
269        let a = mid & 0xff;
270        assert!((100..=155).contains(&r), "r={r}");
271        assert!((100..=155).contains(&g_), "g={g_}");
272        assert!((100..=155).contains(&b), "b={b}");
273        assert_eq!(a, 0xff);
274    }
275
276    #[test]
277    fn sample_picks_correct_segment_with_three_stops() {
278        let g = Gradient::linear(
279            0.0,
280            vec![
281                Stop::new(0.0, 0xFF0000FF), // red
282                Stop::new(0.5, 0x00FF00FF), // green
283                Stop::new(1.0, 0x0000FFFF), // blue
284            ],
285        );
286        // At 0.25 we should be halfway from red to green: ~yellow-ish.
287        let v = g.sample(0.25).unwrap();
288        let r = (v >> 24) & 0xff;
289        let g_ = (v >> 16) & 0xff;
290        let b = (v >> 8) & 0xff;
291        assert!(r > 100 && r < 155, "r={r}");
292        assert!(g_ > 100 && g_ < 155, "g={g_}");
293        assert_eq!(b, 0);
294
295        // At 0.75 we should be halfway from green to blue.
296        let v = g.sample(0.75).unwrap();
297        let r = (v >> 24) & 0xff;
298        let g_ = (v >> 16) & 0xff;
299        let b = (v >> 8) & 0xff;
300        assert_eq!(r, 0);
301        assert!(g_ > 100 && g_ < 155, "g={g_}");
302        assert!(b > 100 && b < 155, "b={b}");
303    }
304
305    #[test]
306    fn empty_gradient_returns_none() {
307        let g = Gradient::linear(0.0, Vec::<Stop>::new());
308        assert!(g.sample(0.5).is_none());
309    }
310
311    #[test]
312    fn radial_constructor_sorts_stops() {
313        let g = Gradient::radial(
314            0.5,
315            0.5,
316            0.5,
317            vec![Stop::new(1.0, 0x000000FF), Stop::new(0.0, 0xFFFFFFFF)],
318        );
319        let s = g.stops();
320        assert_eq!(s.len(), 2);
321        assert_eq!(s[0].offset, 0.0);
322        assert_eq!(s[1].offset, 1.0);
323        if let Gradient::Radial {
324            cx,
325            cy,
326            radius,
327            stops,
328        } = &g
329        {
330            assert_eq!(*cx, 0.5);
331            assert_eq!(*cy, 0.5);
332            assert_eq!(*radius, 0.5);
333            assert_eq!(stops.len(), 2);
334        } else {
335            panic!("expected Radial");
336        }
337    }
338
339    #[test]
340    fn paint_solid_sample_is_constant() {
341        let p = Paint::solid(0x123456FF);
342        assert_eq!(p.sample(0.0), 0x123456FF);
343        assert_eq!(p.sample(0.5), 0x123456FF);
344        assert_eq!(p.sample(1.0), 0x123456FF);
345    }
346
347    #[test]
348    fn paint_gradient_sample_delegates() {
349        let p = Paint::gradient(Gradient::linear(
350            0.0,
351            vec![Stop::new(0.0, 0x000000FF), Stop::new(1.0, 0xFFFFFFFF)],
352        ));
353        let v = p.sample(0.5);
354        let r = (v >> 24) & 0xff;
355        assert!((100..=155).contains(&r));
356    }
357
358    #[test]
359    fn paint_empty_gradient_samples_zero() {
360        let p = Paint::gradient(Gradient::linear(0.0, Vec::<Stop>::new()));
361        assert_eq!(p.sample(0.5), 0);
362    }
363}