Skip to main content

wisp/scene/
vector.rs

1//! Vector shape primitive model — Screen's shared shape language
2//! (M-VEC.1 / AUT-53).
3//!
4//! This is *not* SVG support. It is Wisp's own deterministic vector
5//! primitive model: rect / rounded-rect / circle / ellipse / path /
6//! group, with fill / stroke / opacity / transform.
7//!
8//! One shape language drives every visual tool in Screen — masks,
9//! crops, highlights, callouts, cursor effects — so each tool
10//! doesn't invent its own geometry model. Subsequent issues build
11//! on this:
12//!
13//! - **M-VEC.2 / AUT-54** renders [`Vector`] into the scene as
14//!   visible geometry.
15//! - **M-VEC.3 / AUT-55** renders [`Vector`] into alpha-mask textures
16//!   (bridge to M-DYN.1).
17//! - **M-VEC.4..6** refactors privacy blur / redaction / spotlight
18//!   onto the vector mask path.
19//!
20//! ## Relationship to existing types
21//!
22//! - [`MaskShape`] (the analytic SDF subset) can be derived from a
23//!   compatible [`VectorShape`] via [`VectorShape::as_mask_shape`].
24//!   This lets the existing `apply_clip` / `apply_solid_redaction` /
25//!   etc. consume vector data without a separate code path.
26//! - [`Path` points](VectorShape::Path) carry an owned `Vec<Vec2>`
27//!   so `VectorShape` is `Clone`, not `Copy` (intentional — the path
28//!   variant is the reason `MaskShape::Path` was never added; see
29//!   M-MASK.10's chapter).
30
31use glam::Vec2;
32
33use crate::color::Color;
34use crate::math::Rect;
35use crate::scene::clip::MaskShape;
36use crate::scene::graphics::Fill;
37use crate::scene::transform::Transform;
38
39/// Geometric shape — no paint, no transform. The "what" of a vector
40/// primitive.
41#[derive(Debug, Clone, PartialEq)]
42#[non_exhaustive]
43pub enum VectorShape {
44    /// Sharp-corner rectangle in NDC.
45    Rect {
46        /// Axis-aligned bounding rect.
47        rect: Rect,
48    },
49    /// Rounded rectangle in NDC.
50    RoundedRect {
51        /// Axis-aligned bounding rect.
52        rect: Rect,
53        /// Corner radius in NDC units.
54        radius: f32,
55    },
56    /// Circle in NDC.
57    Circle {
58        /// Center, NDC.
59        center: Vec2,
60        /// Radius, NDC.
61        radius: f32,
62    },
63    /// Anisotropic ellipse in NDC.
64    Ellipse {
65        /// Center, NDC.
66        center: Vec2,
67        /// Half-extents `(a, b)` of the implicit equation
68        /// `(x/a)² + (y/b)² = 1`.
69        half_extents: Vec2,
70    },
71    /// Closed polygon in NDC. Up to 32 vertices honored by the mask
72    /// path (matching `path_clip.wgsl`'s uniform cap).
73    Path {
74        /// Vertex list. `Vec` (not slice) so the variant is owned;
75        /// pay the allocation once per shape construction.
76        points: Vec<Vec2>,
77    },
78}
79
80impl VectorShape {
81    /// Convenience constructor.
82    #[must_use]
83    pub fn rect(rect: Rect) -> Self {
84        Self::Rect { rect }
85    }
86
87    /// Convenience constructor.
88    #[must_use]
89    pub fn rounded_rect(rect: Rect, radius: f32) -> Self {
90        Self::RoundedRect { rect, radius }
91    }
92
93    /// Convenience constructor.
94    #[must_use]
95    pub fn circle(center: Vec2, radius: f32) -> Self {
96        Self::Circle { center, radius }
97    }
98
99    /// Convenience constructor.
100    #[must_use]
101    pub fn ellipse(center: Vec2, half_extents: Vec2) -> Self {
102        Self::Ellipse {
103            center,
104            half_extents,
105        }
106    }
107
108    /// Convenience constructor.
109    #[must_use]
110    pub fn path(points: Vec<Vec2>) -> Self {
111        Self::Path { points }
112    }
113
114    /// Axis-aligned bounding rect of the shape. For paths, computed
115    /// from the point list (returns a zero rect for an empty path).
116    #[must_use]
117    pub fn bounds(&self) -> Rect {
118        match self {
119            Self::Rect { rect } | Self::RoundedRect { rect, .. } => *rect,
120            Self::Circle { center, radius } => Rect::new(
121                center.x - radius,
122                center.y - radius,
123                radius * 2.0,
124                radius * 2.0,
125            ),
126            Self::Ellipse {
127                center,
128                half_extents,
129            } => Rect::new(
130                center.x - half_extents.x,
131                center.y - half_extents.y,
132                half_extents.x * 2.0,
133                half_extents.y * 2.0,
134            ),
135            Self::Path { points } => points_bounds(points),
136        }
137    }
138
139    /// If this shape has an analytic SDF (rect / rounded / circle /
140    /// ellipse), return it as a [`MaskShape`] so the existing mask /
141    /// clip / privacy-blur / redaction / spotlight machinery can
142    /// consume it directly. Returns `None` for [`Self::Path`] —
143    /// callers should use the path-clip / path-mask-texture variants
144    /// instead.
145    #[must_use]
146    pub fn as_mask_shape(&self) -> Option<MaskShape> {
147        match self {
148            Self::Rect { rect } => Some(MaskShape::Rect { rect: *rect }),
149            Self::RoundedRect { rect, radius } => Some(MaskShape::RoundedRect {
150                rect: *rect,
151                radius: *radius,
152            }),
153            Self::Circle { center, radius } => Some(MaskShape::Circle {
154                center: *center,
155                radius: *radius,
156            }),
157            Self::Ellipse {
158                center,
159                half_extents,
160            } => Some(MaskShape::Ellipse {
161                center: *center,
162                half_extents: *half_extents,
163            }),
164            Self::Path { .. } => None,
165        }
166    }
167
168    /// If this is a path, return the points slice. Returns `None`
169    /// for analytic SDF shapes.
170    #[must_use]
171    pub fn as_path_points(&self) -> Option<&[Vec2]> {
172        match self {
173            Self::Path { points } => Some(points),
174            _ => None,
175        }
176    }
177}
178
179fn points_bounds(points: &[Vec2]) -> Rect {
180    if points.is_empty() {
181        return Rect::new(0.0, 0.0, 0.0, 0.0);
182    }
183    let mut min = points[0];
184    let mut max = points[0];
185    for p in &points[1..] {
186        min = min.min(*p);
187        max = max.max(*p);
188    }
189    Rect::new(min.x, min.y, max.x - min.x, max.y - min.y)
190}
191
192/// Stroke paint — width (NDC units) plus solid color.
193///
194/// Gradient strokes are deferred (M-VEC.15 / P2 polish).
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub struct VectorStroke {
197    /// Stroke width, NDC units.
198    pub width: f32,
199    /// Stroke color (RGBA in linear f32).
200    pub color: Color,
201}
202
203impl VectorStroke {
204    /// Convenience constructor.
205    #[must_use]
206    pub fn new(width: f32, color: Color) -> Self {
207        Self { width, color }
208    }
209}
210
211/// Full vector primitive — shape + fill + stroke + opacity +
212/// transform. The "what" + the "how it paints."
213///
214/// `fill` and `stroke` are optional so a `Vector` can be a fill-only
215/// silhouette, a stroke-only outline, or both. For mask use cases
216/// neither is consulted — only [`Vector::shape`] matters.
217#[derive(Debug, Clone, PartialEq)]
218pub struct Vector {
219    /// Geometry.
220    pub shape: VectorShape,
221    /// Optional fill paint. `None` means no fill (stroke-only).
222    pub fill: Option<Fill>,
223    /// Optional stroke paint. `None` means no stroke (fill-only).
224    pub stroke: Option<VectorStroke>,
225    /// Multiplicative opacity in `[0, 1]`. Combines with any alpha
226    /// already present in `fill` / `stroke`.
227    pub opacity: f32,
228    /// Local transform applied to the shape before rasterization.
229    pub transform: Transform,
230}
231
232impl Vector {
233    /// Construct a `Vector` from a shape; no fill, no stroke, full
234    /// opacity, identity transform. Builder methods set the rest.
235    #[must_use]
236    pub fn new(shape: VectorShape) -> Self {
237        Self {
238            shape,
239            fill: None,
240            stroke: None,
241            opacity: 1.0,
242            transform: Transform::default(),
243        }
244    }
245
246    /// Builder: set the fill paint.
247    #[must_use]
248    pub fn with_fill(mut self, fill: Fill) -> Self {
249        self.fill = Some(fill);
250        self
251    }
252
253    /// Builder: set the stroke paint.
254    #[must_use]
255    pub fn with_stroke(mut self, stroke: VectorStroke) -> Self {
256        self.stroke = Some(stroke);
257        self
258    }
259
260    /// Builder: set the opacity. Clamped to `[0, 1]` at apply-time
261    /// (the renderer uses `f32::clamp` when reading).
262    #[must_use]
263    pub fn with_opacity(mut self, opacity: f32) -> Self {
264        self.opacity = opacity;
265        self
266    }
267
268    /// Builder: set the local transform.
269    #[must_use]
270    pub fn with_transform(mut self, transform: Transform) -> Self {
271        self.transform = transform;
272        self
273    }
274
275    /// Convert this vector primitive to a [`Graphics`](crate::scene::Graphics)
276    /// node ready to add to the stage. Honors `fill` / `stroke` /
277    /// `opacity` / `transform`. Returns `None` for [`VectorShape::Path`]
278    /// — visible path rendering is deferred to **M-VEC.10 / AUT-62**;
279    /// today, paths can only drive masks via the path-clip primitives.
280    ///
281    /// `opacity` is folded into the fill + stroke colors as an alpha
282    /// multiplier (the renderer doesn't yet have a per-node opacity
283    /// channel; this is the practical equivalent for V1).
284    #[must_use]
285    pub fn to_graphics(&self) -> Option<crate::scene::Graphics> {
286        use crate::scene::Graphics;
287        let opacity = self.opacity.clamp(0.0, 1.0);
288        let mut g = Graphics::new();
289        g.container.transform = self.transform;
290        if let Some(fill) = self.fill {
291            g.fill(scale_fill_alpha(fill, opacity));
292        }
293        if let Some(stroke) = self.stroke {
294            g.stroke(Some(crate::scene::Stroke {
295                width: stroke.width,
296                color: stroke.color.with_alpha(stroke.color.a * opacity),
297            }));
298        }
299        match &self.shape {
300            VectorShape::Rect { rect } => {
301                g.draw_rect(*rect);
302            }
303            VectorShape::RoundedRect { rect, radius } => {
304                g.draw_rounded_rect(*rect, *radius);
305            }
306            VectorShape::Circle { center, radius } => {
307                g.draw_ellipse(*center, Vec2::splat(*radius));
308            }
309            VectorShape::Ellipse {
310                center,
311                half_extents,
312            } => {
313                g.draw_ellipse(*center, *half_extents);
314            }
315            VectorShape::Path { .. } => return None,
316        }
317        Some(g)
318    }
319
320    /// Add this vector primitive to `stage` under `parent`, honoring
321    /// fill / stroke / opacity / transform. Returns the new node ID,
322    /// or `None` if the shape is a path (deferred to M-VEC.10).
323    pub fn add_to_stage(
324        &self,
325        stage: &mut crate::scene::Stage,
326        parent: crate::scene::NodeId,
327    ) -> Option<crate::scene::NodeId> {
328        let g = self.to_graphics()?;
329        stage.add_child(parent, g)
330    }
331}
332
333fn scale_fill_alpha(fill: Fill, opacity: f32) -> Fill {
334    match fill {
335        Fill::Solid(c) => Fill::Solid(c.with_alpha(c.a * opacity)),
336        Fill::LinearGradient {
337            start,
338            end,
339            color_a,
340            color_b,
341        } => Fill::LinearGradient {
342            start,
343            end,
344            color_a: color_a.with_alpha(color_a.a * opacity),
345            color_b: color_b.with_alpha(color_b.a * opacity),
346        },
347        Fill::RadialGradient {
348            center,
349            radius,
350            color_a,
351            color_b,
352        } => Fill::RadialGradient {
353            center,
354            radius,
355            color_a: color_a.with_alpha(color_a.a * opacity),
356            color_b: color_b.with_alpha(color_b.a * opacity),
357        },
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn rect_bounds_match_rect() {
367        let r = Rect::new(-0.5, -0.3, 1.0, 0.6);
368        let bounds = VectorShape::rect(r).bounds();
369        assert!((bounds.min.x - r.min.x).abs() < 1e-6);
370        assert!((bounds.size.x - r.size.x).abs() < 1e-6);
371    }
372
373    #[test]
374    fn circle_bounds_are_2r_square() {
375        let bounds = VectorShape::circle(Vec2::new(0.1, -0.2), 0.3).bounds();
376        assert!((bounds.size.x - 0.6).abs() < 1e-6);
377        assert!((bounds.size.y - 0.6).abs() < 1e-6);
378        assert!((bounds.min.x - (-0.2)).abs() < 1e-6);
379        assert!((bounds.min.y - (-0.5)).abs() < 1e-6);
380    }
381
382    #[test]
383    fn ellipse_bounds_are_anisotropic() {
384        let bounds = VectorShape::ellipse(Vec2::ZERO, Vec2::new(0.7, 0.3)).bounds();
385        assert!((bounds.size.x - 1.4).abs() < 1e-6);
386        assert!((bounds.size.y - 0.6).abs() < 1e-6);
387    }
388
389    #[test]
390    fn path_bounds_min_max_extent() {
391        let pts = vec![
392            Vec2::new(-0.4, 0.1),
393            Vec2::new(0.2, -0.3),
394            Vec2::new(0.5, 0.6),
395        ];
396        let bounds = VectorShape::path(pts).bounds();
397        assert!((bounds.min.x - (-0.4)).abs() < 1e-6);
398        assert!((bounds.min.y - (-0.3)).abs() < 1e-6);
399        assert!((bounds.size.x - 0.9).abs() < 1e-6);
400        assert!((bounds.size.y - 0.9).abs() < 1e-6);
401    }
402
403    #[test]
404    fn empty_path_bounds_is_zero() {
405        let bounds = VectorShape::path(Vec::new()).bounds();
406        assert!(bounds.size.x.abs() < 1e-6);
407        assert!(bounds.size.y.abs() < 1e-6);
408    }
409
410    #[test]
411    fn analytic_shapes_round_trip_to_mask_shape() {
412        let r = Rect::new(-0.5, -0.5, 1.0, 1.0);
413        assert!(matches!(
414            VectorShape::rect(r).as_mask_shape(),
415            Some(MaskShape::Rect { .. })
416        ));
417        assert!(matches!(
418            VectorShape::rounded_rect(r, 0.2).as_mask_shape(),
419            Some(MaskShape::RoundedRect { .. })
420        ));
421        assert!(matches!(
422            VectorShape::circle(Vec2::ZERO, 0.4).as_mask_shape(),
423            Some(MaskShape::Circle { .. })
424        ));
425        assert!(matches!(
426            VectorShape::ellipse(Vec2::ZERO, Vec2::new(0.7, 0.3)).as_mask_shape(),
427            Some(MaskShape::Ellipse { .. })
428        ));
429    }
430
431    #[test]
432    fn path_does_not_round_trip_to_mask_shape() {
433        let path = VectorShape::path(vec![Vec2::ZERO, Vec2::new(0.5, 0.5)]);
434        assert!(path.as_mask_shape().is_none());
435        assert_eq!(path.as_path_points().map(<[_]>::len), Some(2));
436    }
437
438    #[test]
439    fn vector_default_opacity_is_one() {
440        let v = Vector::new(VectorShape::rect(Rect::new(-0.5, -0.5, 1.0, 1.0)));
441        assert!((v.opacity - 1.0).abs() < f32::EPSILON);
442        assert!(v.fill.is_none());
443        assert!(v.stroke.is_none());
444    }
445
446    #[test]
447    fn vector_builder_chains() {
448        let v = Vector::new(VectorShape::circle(Vec2::ZERO, 0.5))
449            .with_fill(Fill::Solid(Color::rgba(1.0, 0.5, 0.0, 1.0)))
450            .with_stroke(VectorStroke::new(0.02, Color::WHITE))
451            .with_opacity(0.8);
452        assert!(v.fill.is_some());
453        assert!(v.stroke.is_some());
454        assert!((v.opacity - 0.8).abs() < f32::EPSILON);
455    }
456}