Skip to main content

bevy_react/svg/
paint.rs

1//! Pure tiny-skia painting for JSX `<svg>` shape children: coordinate math,
2//! per-kind path construction, and single-shape fill/stroke. No ECS and no
3//! systems — the raster walker (which descends the `<svg>` root's children,
4//! composing group transforms and opacity) calls [`paint_shape`] once per
5//! **leaf** with the composed values; groups have no geometry and are never
6//! painted directly.
7//!
8//! **viewBox fitting is `xMidYMid meet` only** in v1: there is no
9//! `preserveAspectRatio` wire prop, so no stretch/slice modes exist here. The
10//! math intentionally mirrors [`rasterize_document`](super::rasterize_document)
11//! (file mode) — both call the shared [`meet_transform`].
12//!
13//! Geometry rules (documented here because the hit-tester must mirror them
14//! exactly — one geometry contract, two backends):
15//!
16//! - Absent geometry attributes default to **0** (web behavior); a shape whose
17//!   resolved geometry is degenerate returns `None` from [`shape_path`] and is
18//!   not rendered — never a panic, including NaN/∞ input (tiny-skia's builders
19//!   reject non-finite bounds).
20//! - **rect**: `width`/`height` non-finite or ≤ 0 → not rendered. Radii follow
21//!   the SVG auto rule: an *absent* radius takes the other's value; a negative
22//!   or non-finite radius is invalid and treated as absent (auto); an
23//!   **explicit 0** on either axis disables rounding entirely (SVG2); radii
24//!   clamp to half the extents. Rounded corners are cubic arc approximations
25//!   ([`KAPPA`]).
26//! - **circle**: `r` non-finite or ≤ 0 → not rendered.
27//! - **ellipse**: same auto rule as rect radii (one absent radius mirrors the
28//!   other); either resolved radius non-finite or ≤ 0 → not rendered.
29//! - **line**: just Move+Line; a line has no interior, so fill is skipped
30//!   explicitly in [`paint_shape`].
31//! - **polyline/polygon**: fewer than 2 points → not rendered; polygon closes
32//!   its outline, polyline stays open — but SVG `fill` treats an open subpath
33//!   as implicitly closed (tiny-skia's fill does too), so both fill the same
34//!   interior; only the stroke differs.
35//! - **path**: replays the pre-parsed absolute [`PathSeg`] list; absent/empty
36//!   `d` → not rendered.
37//! - **group**: no geometry, always `None`.
38//!
39//! Paint rules: absent `fill` → black, explicit `"none"` → skip; absent
40//! `stroke` or `"none"` → skip; `strokeWidth` defaults to 1.0, non-finite or
41//! ≤ 0 skips the stroke (0 is the spec's no-stroke; negative is invalid and
42//! treated the same). Stroke width is in **user units**: tiny-skia outlines
43//! the stroke in path space and then transforms, so the viewBox scale scales
44//! the ink (pinned by test). Effective alpha = paint alpha × `opacity` ×
45//! inherited opacity. `attrs.transform` composes **inside** the passed
46//! transform (shape transform first, then viewBox/DPR — `pre_concat`, pinned
47//! by test).
48
49use bevy::color::Srgba;
50use bevy::math::Vec2;
51use tiny_skia::{FillRule, LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, Stroke, Transform};
52
53use super::{
54    FillRuleKind, LinecapKind, LinejoinKind, PathData, PathSeg, ShapeAttrs, ShapeKind, ShapePaint,
55    ShapeTransform, ViewBox,
56};
57use crate::protocol::{animatable::Animatable, animatable::AnimatableField};
58
59#[cfg(test)]
60mod tests;
61
62/// Cubic-Bézier quarter-circle control-point distance (as a fraction of the
63/// radius): the standard 4-cubic circle approximation constant. Shared with
64/// [`super::hit`] so the hit-tester's arcs are the painter's arcs.
65pub(super) const KAPPA: f32 = 0.552_284_8;
66
67/// [`ShapeTransform`] (SVG matrix order `[a b c d e f]`) → tiny-skia — the
68/// same field order. Lives here rather than on the wire type so the protocol
69/// module stays raster-agnostic (its documented rule).
70impl From<&ShapeTransform> for Transform {
71    fn from(t: &ShapeTransform) -> Self {
72        let ShapeTransform([a, b, c, d, e, f]) = *t;
73        Transform::from_row(a, b, c, d, e, f)
74    }
75}
76
77/// Uniform `xMidYMid meet` fit of the content rect (`min`, `size`, user
78/// units) into a `w`×`h` pixel box: scale by the tighter axis, center the
79/// slack axis, translating by `-min` first. Shared by the file-mode
80/// rasterizer ([`super::rasterize_document`]) and [`view_box_transform`].
81/// `size` must be positive (callers guard).
82pub(crate) fn meet_transform(min: Vec2, size: Vec2, w: u32, h: u32) -> Transform {
83    let scale = (w as f32 / size.x).min(h as f32 / size.y);
84    let tx = (w as f32 - size.x * scale) * 0.5 - min.x * scale;
85    let ty = (h as f32 - size.y * scale) * 0.5 - min.y * scale;
86    Transform::from_row(scale, 0.0, 0.0, scale, tx, ty)
87}
88
89/// The user-unit → physical-pixel transform of a JSX `<svg>` element.
90///
91/// - `None`: logical-pixel space — 1 user unit = 1 logical px of the laid-out
92///   box, so the only scaling is the device pixel ratio (`scale_factor`).
93/// - `Some`: `xMidYMid meet` (the only mode in v1 — no `preserveAspectRatio`
94///   wire prop exists). The viewBox maps onto the whole physical box, which
95///   already includes the DPR, so `scale_factor` plays no part.
96///
97/// A degenerate viewBox (non-positive size — unreachable via the wire, the
98/// parser rejects it) falls back to logical-pixel space.
99pub(crate) fn view_box_transform(
100    view_box: Option<&ViewBox>,
101    w_px: u32,
102    h_px: u32,
103    scale_factor: f32,
104) -> Transform {
105    match view_box {
106        Some(vb) if vb.size.x > 0.0 && vb.size.y > 0.0 => {
107            meet_transform(vb.min, vb.size, w_px, h_px)
108        }
109        _ => Transform::from_scale(scale_factor, scale_factor),
110    }
111}
112
113/// Build the outline of one shape in user units, or `None` when the shape's
114/// geometry is degenerate (see the module doc for the per-kind rules — the
115/// hit-tester mirrors them).
116pub(crate) fn shape_path(kind: ShapeKind, attrs: &ShapeAttrs) -> Option<Path> {
117    // Absent geometry defaults to 0 (web behavior); an animated attr
118    // reads its seed, or counts as absent until the driver writes.
119    let g = |v: &Option<Animatable<f32>>| v.static_or_seed().unwrap_or(0.0);
120    match kind {
121        ShapeKind::Rect => rect_path(attrs),
122        ShapeKind::Circle => {
123            let r = g(&attrs.r);
124            if !(r.is_finite() && r > 0.0) {
125                return None;
126            }
127            ellipse_path(g(&attrs.cx), g(&attrs.cy), r, r)
128        }
129        ShapeKind::Ellipse => {
130            // Auto rule: an absent radius mirrors the other; invalid
131            // (negative/non-finite) counts as absent.
132            let rx = valid_radius(attrs.rx.static_or_seed())
133                .or(valid_radius(attrs.ry.static_or_seed()))
134                .unwrap_or(0.0);
135            let ry = valid_radius(attrs.ry.static_or_seed())
136                .or(valid_radius(attrs.rx.static_or_seed()))
137                .unwrap_or(0.0);
138            if rx <= 0.0 || ry <= 0.0 {
139                return None;
140            }
141            ellipse_path(g(&attrs.cx), g(&attrs.cy), rx, ry)
142        }
143        ShapeKind::Line => {
144            let mut pb = PathBuilder::new();
145            pb.move_to(g(&attrs.x1), g(&attrs.y1));
146            pb.line_to(g(&attrs.x2), g(&attrs.y2));
147            pb.finish()
148        }
149        ShapeKind::Polyline => poly_path(attrs.points.as_deref(), false),
150        ShapeKind::Polygon => poly_path(attrs.points.as_deref(), true),
151        ShapeKind::Path => replay_path(attrs.d.as_ref()?),
152        ShapeKind::Group => None,
153    }
154}
155
156/// Paint ONE leaf shape onto the pixmap: fill (SVG default black) then stroke
157/// (SVG default none), with `attrs.transform` composed inside `transform` and
158/// `inherited_opacity` (the walker's composed group opacity) multiplied into
159/// both paints. See the module doc for the full default/degenerate rules.
160pub(crate) fn paint_shape(
161    pixmap: &mut Pixmap,
162    kind: ShapeKind,
163    attrs: &ShapeAttrs,
164    transform: Transform,
165    inherited_opacity: f32,
166) {
167    let Some(path) = shape_path(kind, attrs) else {
168        return;
169    };
170    // Shape transform first, then the outer (viewBox/DPR) transform:
171    // pre_concat maps p → transform(shape_t(p)). Pinned by test.
172    let full = match &attrs.transform {
173        Some(t) => transform.pre_concat(t.into()),
174        None => transform,
175    };
176    let opacity = attrs
177        .opacity
178        .static_or_seed()
179        .unwrap_or(1.0)
180        .clamp(0.0, 1.0)
181        * inherited_opacity.clamp(0.0, 1.0);
182
183    // Fill: absent → SVG-default black; explicit "none" → skip. A line has no
184    // interior, so skip its fill outright (it would only no-op anyway).
185    if kind != ShapeKind::Line {
186        match attrs.fill.unwrap_or(ShapePaint::Color(Srgba::BLACK)) {
187            ShapePaint::Color(c) => {
188                let rule = match attrs.fill_rule.unwrap_or(FillRuleKind::NonZero) {
189                    FillRuleKind::NonZero => FillRule::Winding,
190                    FillRuleKind::EvenOdd => FillRule::EvenOdd,
191                };
192                pixmap.fill_path(&path, &solid(c, opacity), rule, full, None);
193            }
194            ShapePaint::None => {}
195        }
196    }
197
198    // Stroke: absent and "none" both skip. Width is in user units — the
199    // transform scales the ink (pinned by test).
200    match attrs.stroke {
201        Some(ShapePaint::Color(c)) => {
202            let width = attrs.stroke_width.static_or_seed().unwrap_or(1.0);
203            // 0 = the spec's no-stroke; negative/NaN invalid, treated the same.
204            // A zero-extent path (single point / empty) has an empty butt-cap
205            // outline; tiny-skia's stroker warns "path stroking failed" on it,
206            // so skip like the canvas does. v1 limit: zero-length subpaths
207            // never draw round/square cap dots.
208            let b = path.bounds();
209            if width.is_finite() && width > 0.0 && (b.width() > 0.0 || b.height() > 0.0) {
210                let stroke = Stroke {
211                    width,
212                    line_cap: match attrs.stroke_linecap.unwrap_or(LinecapKind::Butt) {
213                        LinecapKind::Butt => LineCap::Butt,
214                        LinecapKind::Round => LineCap::Round,
215                        LinecapKind::Square => LineCap::Square,
216                    },
217                    line_join: match attrs.stroke_linejoin.unwrap_or(LinejoinKind::Miter) {
218                        LinejoinKind::Miter => LineJoin::Miter,
219                        LinejoinKind::Round => LineJoin::Round,
220                        LinejoinKind::Bevel => LineJoin::Bevel,
221                    },
222                    ..Stroke::default()
223                };
224                pixmap.stroke_path(&path, &solid(c, opacity), &stroke, full, None);
225            }
226        }
227        Some(ShapePaint::None) | None => {}
228    }
229}
230
231/// A radius attribute value usable as an explicit radius: negative or
232/// non-finite is invalid per SVG2 and treated as absent (auto). Note an
233/// explicit `0.0` **passes** — "explicitly zero" is meaningful (it disables
234/// rect rounding and un-renders an ellipse axis), unlike "absent". Shared
235/// with [`super::hit`] so both sides resolve radii identically.
236pub(super) fn valid_radius(v: Option<f32>) -> Option<f32> {
237    v.filter(|v| v.is_finite() && *v >= 0.0)
238}
239
240/// `<rect>`: sharp when either resolved radius is 0, else rounded corners as
241/// four cubic arcs. See the module doc for the radius auto/clamp rules.
242fn rect_path(attrs: &ShapeAttrs) -> Option<Path> {
243    let (w, h) = (
244        attrs.width.static_or_seed().unwrap_or(0.0),
245        attrs.height.static_or_seed().unwrap_or(0.0),
246    );
247    if !(w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0) {
248        return None;
249    }
250    let (x, y) = (
251        attrs.x.static_or_seed().unwrap_or(0.0),
252        attrs.y.static_or_seed().unwrap_or(0.0),
253    );
254    let rx = valid_radius(attrs.rx.static_or_seed())
255        .or(valid_radius(attrs.ry.static_or_seed()))
256        .unwrap_or(0.0);
257    let ry = valid_radius(attrs.ry.static_or_seed())
258        .or(valid_radius(attrs.rx.static_or_seed()))
259        .unwrap_or(0.0);
260    let (rx, ry) = (rx.min(w * 0.5), ry.min(h * 0.5));
261    let mut pb = PathBuilder::new();
262    if rx <= 0.0 || ry <= 0.0 {
263        // SVG2: a zero radius on either axis disables rounding entirely.
264        pb.push_rect(tiny_skia::Rect::from_xywh(x, y, w, h)?);
265        return pb.finish();
266    }
267    let (kx, ky) = (rx * KAPPA, ry * KAPPA);
268    let (r, b) = (x + w, y + h); // right, bottom
269    pb.move_to(x + rx, y);
270    pb.line_to(r - rx, y);
271    pb.cubic_to(r - rx + kx, y, r, y + ry - ky, r, y + ry);
272    pb.line_to(r, b - ry);
273    pb.cubic_to(r, b - ry + ky, r - rx + kx, b, r - rx, b);
274    pb.line_to(x + rx, b);
275    pb.cubic_to(x + rx - kx, b, x, b - ry + ky, x, b - ry);
276    pb.line_to(x, y + ry);
277    pb.cubic_to(x, y + ry - ky, x + rx - kx, y, x + rx, y);
278    pb.close();
279    pb.finish()
280}
281
282/// An axis-aligned ellipse as four cubic arcs ([`KAPPA`]), starting at the
283/// rightmost point, winding clockwise (+y down). Radii must be positive.
284fn ellipse_path(cx: f32, cy: f32, rx: f32, ry: f32) -> Option<Path> {
285    let (kx, ky) = (rx * KAPPA, ry * KAPPA);
286    let mut pb = PathBuilder::new();
287    pb.move_to(cx + rx, cy);
288    pb.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
289    pb.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
290    pb.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
291    pb.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
292    pb.close();
293    pb.finish()
294}
295
296/// `<polyline>`/`<polygon>` outline; fewer than 2 points renders nothing.
297fn poly_path(points: Option<&[Vec2]>, close: bool) -> Option<Path> {
298    let pts = points.unwrap_or(&[]);
299    if pts.len() < 2 {
300        return None;
301    }
302    let mut pb = PathBuilder::new();
303    pb.move_to(pts[0].x, pts[0].y);
304    for p in &pts[1..] {
305        pb.line_to(p.x, p.y);
306    }
307    if close {
308        pb.close();
309    }
310    pb.finish()
311}
312
313/// Replay pre-parsed absolute path segments (parse guarantees the list starts
314/// with a MoveTo). An empty list finishes to `None` (paint-nothing).
315fn replay_path(d: &PathData) -> Option<Path> {
316    let mut pb = PathBuilder::new();
317    for seg in &d.0 {
318        match *seg {
319            PathSeg::MoveTo { x, y } => pb.move_to(x, y),
320            PathSeg::LineTo { x, y } => pb.line_to(x, y),
321            PathSeg::QuadTo { c1x, c1y, x, y } => pb.quad_to(c1x, c1y, x, y),
322            PathSeg::CubicTo {
323                c1x,
324                c1y,
325                c2x,
326                c2y,
327                x,
328                y,
329            } => pb.cubic_to(c1x, c1y, c2x, c2y, x, y),
330            PathSeg::Close => pb.close(),
331        }
332    }
333    pb.finish()
334}
335
336/// An anti-aliased solid paint from a straight-alpha [`Srgba`] with the
337/// composed opacity multiplied in (the [`crate::canvas`] `solid()` pattern,
338/// taking `Srgba` + opacity instead of bytes). Non-finite components fall
339/// back to opaque black, matching the canvas color fallback.
340fn solid(c: Srgba, opacity: f32) -> Paint<'static> {
341    let mut paint = Paint {
342        anti_alias: true,
343        ..Paint::default()
344    };
345    let color = tiny_skia::Color::from_rgba(
346        c.red.clamp(0.0, 1.0),
347        c.green.clamp(0.0, 1.0),
348        c.blue.clamp(0.0, 1.0),
349        (c.alpha * opacity).clamp(0.0, 1.0),
350    )
351    .unwrap_or(tiny_skia::Color::BLACK);
352    paint.set_color(color);
353    paint
354}