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}