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}