Skip to main content

wisp/scene/
graphics.rs

1//! `Graphics` — vector primitives (rect, rounded rect, ellipse, line, stroke).
2//!
3//! Composed over [`Container`]. A `Graphics` holds a list of primitives that
4//! are rendered with a shared SDF-based pipeline. Primitives may have an
5//! optional [`Stroke`] outline, set via [`Graphics::stroke`] before drawing.
6
7use glam::Vec2;
8
9use crate::color::Color;
10use crate::math::Rect;
11use crate::scene::container::Container;
12
13/// Fill style for a `Graphics` primitive.
14///
15/// Gradient endpoints (`start`/`end` for linear, `center`/`radius` for radial)
16/// are in primitive-local coordinates: `[-half_extents, +half_extents]`.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum Fill {
19    /// Single solid color (linear-srgb f32).
20    Solid(Color),
21    /// Linear gradient between two colors along `start → end`.
22    LinearGradient {
23        /// Gradient start position (color = `color_a`).
24        start: Vec2,
25        /// Gradient end position (color = `color_b`).
26        end: Vec2,
27        /// Color at `start`.
28        color_a: Color,
29        /// Color at `end`.
30        color_b: Color,
31    },
32    /// Radial gradient from `center` outward to `radius`, blending two colors.
33    RadialGradient {
34        /// Gradient center.
35        center: Vec2,
36        /// Distance at which the gradient reaches `color_b`.
37        radius: f32,
38        /// Color at `center`.
39        color_a: Color,
40        /// Color at `radius`.
41        color_b: Color,
42    },
43}
44
45impl Default for Fill {
46    fn default() -> Self {
47        Self::Solid(Color::WHITE)
48    }
49}
50
51/// Outline style for fillable primitives.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Stroke {
54    /// Outline thickness in primitive-local units.
55    pub width: f32,
56    /// Outline color.
57    pub color: Color,
58}
59
60impl Stroke {
61    /// Construct a new `Stroke`.
62    #[must_use]
63    pub const fn new(width: f32, color: Color) -> Self {
64        Self { width, color }
65    }
66}
67
68/// One drawable primitive within a `Graphics` node.
69///
70/// Internal — the public API is `Graphics::draw_*`.
71#[derive(Debug, Clone)]
72pub(crate) enum Primitive {
73    /// Axis-aligned rectangle. `radius == 0.0` = sharp corners.
74    RoundedRect {
75        rect: Rect,
76        radius: f32,
77        fill: Fill,
78        stroke: Option<Stroke>,
79    },
80    /// Axis-aligned ellipse defined by center and radii.
81    Ellipse {
82        center: Vec2,
83        radii: Vec2,
84        fill: Fill,
85        stroke: Option<Stroke>,
86    },
87    /// Line segment rendered as a rotated rect of given width.
88    Line {
89        from: Vec2,
90        to: Vec2,
91        width: f32,
92        fill: Fill,
93    },
94    /// Annular sector — pie slice (`r_inner = 0`), donut slice
95    /// (`r_inner > 0`), or stroked arc (`r_outer - r_inner` =
96    /// stroke band thickness).
97    ///
98    /// Angles are radians; `0` aligns with `+x`, CCW positive.
99    /// `start_angle < end_angle`; angular span clamps to
100    /// `[0, 2π]`. When `end - start ≥ 2π` the primitive becomes a
101    /// full ring (or filled disc when `r_inner = 0`).
102    AnnularSector {
103        center: Vec2,
104        r_inner: f32,
105        r_outer: f32,
106        start_angle: f32,
107        end_angle: f32,
108        fill: Fill,
109        stroke: Option<Stroke>,
110    },
111    /// Convex polygon defined by a vertex list in
112    /// counter-clockwise winding order. The polygon is implicitly
113    /// closed — the last vertex connects back to the first.
114    ///
115    /// **Convex-only for v1.** Non-convex input is undefined
116    /// behaviour today (fan triangulation produces visible
117    /// overlap). Non-convex support deferred to a follow-on
118    /// tessellator chunk.
119    ///
120    /// No edge anti-aliasing in v1 — polygon edges show pixel
121    /// jaggies. Apply an outline stroke (separate `draw_line`
122    /// calls along the perimeter) when crisp edges matter; SDF
123    /// AA polish is a follow-on.
124    Polygon { vertices: Vec<Vec2>, fill: Fill },
125}
126
127/// Vector-primitive node. Holds an ordered list of primitives sharing the
128/// node's container transform.
129#[derive(Debug, Clone, Default)]
130pub struct Graphics {
131    /// Transform / visibility container.
132    pub container: Container,
133    current_fill: Fill,
134    current_stroke: Option<Stroke>,
135    pub(crate) primitives: Vec<Primitive>,
136}
137
138impl Graphics {
139    /// Construct an empty `Graphics` with default (white solid) fill and no stroke.
140    #[must_use]
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Set the fill used by subsequent `draw_*` calls.
146    pub fn fill(&mut self, fill: Fill) -> &mut Self {
147        self.current_fill = fill;
148        self
149    }
150
151    /// Set the stroke used by subsequent fillable `draw_*` calls.
152    /// Pass `None` to clear; pass `Some(...)` to enable an outline.
153    pub fn stroke(&mut self, stroke: Option<Stroke>) -> &mut Self {
154        self.current_stroke = stroke;
155        self
156    }
157
158    /// Append a filled (and optionally stroked) rectangle primitive.
159    pub fn draw_rect(&mut self, rect: Rect) -> &mut Self {
160        self.primitives.push(Primitive::RoundedRect {
161            rect,
162            radius: 0.0,
163            fill: self.current_fill,
164            stroke: self.current_stroke,
165        });
166        self
167    }
168
169    /// Append a filled (and optionally stroked) rounded rectangle primitive.
170    pub fn draw_rounded_rect(&mut self, rect: Rect, radius: f32) -> &mut Self {
171        self.primitives.push(Primitive::RoundedRect {
172            rect,
173            radius,
174            fill: self.current_fill,
175            stroke: self.current_stroke,
176        });
177        self
178    }
179
180    /// Append a filled (and optionally stroked) ellipse primitive.
181    pub fn draw_ellipse(&mut self, center: Vec2, radii: Vec2) -> &mut Self {
182        self.primitives.push(Primitive::Ellipse {
183            center,
184            radii,
185            fill: self.current_fill,
186            stroke: self.current_stroke,
187        });
188        self
189    }
190
191    /// Append a line segment of the given width, colored with the current fill.
192    /// Strokes do not apply to lines.
193    pub fn draw_line(&mut self, from: Vec2, to: Vec2, width: f32) -> &mut Self {
194        self.primitives.push(Primitive::Line {
195            from,
196            to,
197            width,
198            fill: self.current_fill,
199        });
200        self
201    }
202
203    /// Append a filled (and optionally stroked) annular sector —
204    /// a pie slice (when `r_inner = 0`) or donut slice. Angles in
205    /// radians, `0 = +x` axis, CCW positive. The angular span is
206    /// `end_angle - start_angle`; values are clamped to
207    /// `[0, 2π]`.
208    pub fn draw_annular_sector(
209        &mut self,
210        center: Vec2,
211        r_inner: f32,
212        r_outer: f32,
213        start_angle: f32,
214        end_angle: f32,
215    ) -> &mut Self {
216        self.primitives.push(Primitive::AnnularSector {
217            center,
218            r_inner,
219            r_outer,
220            start_angle,
221            end_angle,
222            fill: self.current_fill,
223            stroke: self.current_stroke,
224        });
225        self
226    }
227
228    /// Append a stroked arc — circular segment of the given
229    /// `radius`, drawn with `stroke_width` band thickness from
230    /// `start_angle` to `end_angle`. Internally an annular sector
231    /// with `r_inner = radius - stroke_width / 2`,
232    /// `r_outer = radius + stroke_width / 2`.
233    pub fn draw_arc(
234        &mut self,
235        center: Vec2,
236        radius: f32,
237        start_angle: f32,
238        end_angle: f32,
239        stroke_width: f32,
240    ) -> &mut Self {
241        let half = stroke_width * 0.5;
242        self.primitives.push(Primitive::AnnularSector {
243            center,
244            r_inner: (radius - half).max(0.0),
245            r_outer: radius + half,
246            start_angle,
247            end_angle,
248            fill: self.current_fill,
249            stroke: None,
250        });
251        self
252    }
253
254    /// Append a filled **convex** polygon. Vertices listed in
255    /// CCW winding order; the polygon is implicitly closed.
256    ///
257    /// Non-convex input is undefined behaviour for v1 — fan
258    /// triangulation from the first vertex produces visible
259    /// overlap when the polygon isn't convex. Strokes do not
260    /// apply to polygons in v1; outline a polygon with `draw_line`
261    /// segments along the perimeter when an outline is needed.
262    pub fn draw_polygon(&mut self, vertices: &[Vec2]) -> &mut Self {
263        if vertices.len() < 3 {
264            return self;
265        }
266        self.primitives.push(Primitive::Polygon {
267            vertices: vertices.to_vec(),
268            fill: self.current_fill,
269        });
270        self
271    }
272
273    /// Number of primitives currently buffered.
274    #[must_use]
275    pub fn primitive_count(&self) -> usize {
276        self.primitives.len()
277    }
278
279    /// Append all primitives from `other` onto this `Graphics`,
280    /// preserving each primitive's captured fill/stroke. The
281    /// current node's `current_fill` / `current_stroke` state is
282    /// unchanged — subsequent `draw_*` calls keep using the
283    /// existing state.
284    ///
285    /// Composing two `Graphics` is the idiomatic way for higher
286    /// layers (e.g. `wisp-chart`'s axis renderer) to splice
287    /// independently-built primitive lists into a single node so
288    /// the whole chart submits as one draw batch.
289    pub fn append(&mut self, other: &Graphics) -> &mut Self {
290        self.primitives.extend(other.primitives.iter().cloned());
291        self
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn new_starts_empty() {
301        let g = Graphics::new();
302        assert_eq!(g.primitive_count(), 0);
303    }
304
305    #[test]
306    fn draw_rect_appends_one_primitive() {
307        let mut g = Graphics::new();
308        g.draw_rect(Rect::new(0.0, 0.0, 10.0, 10.0));
309        assert_eq!(g.primitive_count(), 1);
310    }
311
312    #[test]
313    fn draw_ellipse_appends_one_primitive() {
314        let mut g = Graphics::new();
315        g.draw_ellipse(Vec2::ZERO, Vec2::splat(5.0));
316        assert_eq!(g.primitive_count(), 1);
317    }
318
319    #[test]
320    fn draw_line_appends_one_primitive() {
321        let mut g = Graphics::new();
322        g.draw_line(Vec2::ZERO, Vec2::new(10.0, 0.0), 1.0);
323        assert_eq!(g.primitive_count(), 1);
324    }
325
326    #[test]
327    fn fill_persists_until_changed() {
328        let mut g = Graphics::new();
329        g.fill(Fill::Solid(Color::RED));
330        g.draw_rect(Rect::new(0.0, 0.0, 1.0, 1.0));
331        g.draw_rect(Rect::new(1.0, 0.0, 1.0, 1.0));
332        for p in &g.primitives {
333            if let Primitive::RoundedRect { fill, .. } = p {
334                assert_eq!(*fill, Fill::Solid(Color::RED));
335            }
336        }
337    }
338
339    #[test]
340    fn stroke_persists_until_cleared() {
341        let mut g = Graphics::new();
342        g.stroke(Some(Stroke::new(2.0, Color::BLACK)));
343        g.draw_rect(Rect::new(0.0, 0.0, 1.0, 1.0));
344        g.stroke(None);
345        g.draw_rect(Rect::new(1.0, 0.0, 1.0, 1.0));
346
347        match &g.primitives[0] {
348            Primitive::RoundedRect { stroke, .. } => assert!(stroke.is_some()),
349            _ => panic!("expected rounded rect"),
350        }
351        match &g.primitives[1] {
352            Primitive::RoundedRect { stroke, .. } => assert!(stroke.is_none()),
353            _ => panic!("expected rounded rect"),
354        }
355    }
356}