Skip to main content

rustmotion_components/
svg.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{
4    Canvas, ColorType, ImageInfo, Matrix, Paint, PaintStyle, Path, PathBuilder, PathMeasure, Rect,
5};
6
7use rustmotion_core::css::CssStyle;
8use rustmotion_core::engine::animator::AnimatedProperties;
9use rustmotion_core::engine::layout_pass::BoxLayout;
10use rustmotion_core::engine::renderer::asset_cache;
11use rustmotion_core::schema::TimelineStep;
12use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
13
14#[derive(Debug, Serialize, Deserialize, JsonSchema)]
15pub struct Svg {
16    #[serde(default)]
17    pub src: Option<String>,
18    #[serde(default)]
19    pub data: Option<String>,
20    #[serde(flatten)]
21    pub timing: TimingConfig,
22    #[serde(default)]
23    pub style: CssStyle,
24    #[serde(default)]
25    pub timeline: Vec<TimelineStep>,
26    #[serde(default)]
27    pub stagger: Option<f32>,
28    /// Force draw-on mode even when draw_progress is 1.0 (static draw trace view, no animation needed).
29    #[serde(default)]
30    pub draw: bool,
31    /// Stroke width used when tracing fill-only paths (no stroke in the SVG).
32    #[serde(default = "default_draw_stroke_width")]
33    pub draw_stroke_width: f32,
34    /// Overlap factor between paths during draw-on animation.
35    /// 0.0 = strictly sequential (default); 1.0 = all paths drawn in parallel.
36    #[serde(default)]
37    pub draw_overlap: f32,
38}
39
40fn default_draw_stroke_width() -> f32 {
41    2.0
42}
43
44rustmotion_core::impl_traits!(Svg {
45    Animatable => animation,
46    Timed => timing,
47    Styled => style,
48});
49
50// ────────────────────────────────────────────────────────────────────────────
51// usvg → Skia path conversion
52// ────────────────────────────────────────────────────────────────────────────
53
54/// Convert a `tiny_skia::Path` (from usvg) with an `abs_transform` into a
55/// `skia_safe::Path`, applying the transform inline. The resulting path is in
56/// SVG-space coordinates (pre-layout-scale); callers apply the layout scale via
57/// a canvas save/scale.
58fn tiny_path_to_skia(tsp: &tiny_skia::Path, abs_transform: tiny_skia::Transform) -> Path {
59    // Build the absolute-transform matrix for Skia.
60    // tiny_skia::Transform { sx, ky, kx, sy, tx, ty } (column-major) → Skia Matrix:
61    // new_all(scale_x, skew_x, trans_x, skew_y, scale_y, trans_y, pers0, pers1, pers2)
62    let t = abs_transform;
63    let matrix = Matrix::new_all(t.sx, t.kx, t.tx, t.ky, t.sy, t.ty, 0.0, 0.0, 1.0);
64
65    let mut skia_path = PathBuilder::new();
66    for segment in tsp.segments() {
67        match segment {
68            tiny_skia::PathSegment::MoveTo(p) => {
69                let pt = matrix.map_point((p.x, p.y));
70                skia_path.move_to(pt);
71            }
72            tiny_skia::PathSegment::LineTo(p) => {
73                let pt = matrix.map_point((p.x, p.y));
74                skia_path.line_to(pt);
75            }
76            tiny_skia::PathSegment::QuadTo(p1, p2) => {
77                let cp = matrix.map_point((p1.x, p1.y));
78                let ep = matrix.map_point((p2.x, p2.y));
79                skia_path.quad_to(cp, ep);
80            }
81            tiny_skia::PathSegment::CubicTo(p1, p2, p3) => {
82                let cp1 = matrix.map_point((p1.x, p1.y));
83                let cp2 = matrix.map_point((p2.x, p2.y));
84                let ep = matrix.map_point((p3.x, p3.y));
85                skia_path.cubic_to(cp1, cp2, ep);
86            }
87            tiny_skia::PathSegment::Close => {
88                skia_path.close();
89            }
90        }
91    }
92    skia_path.detach()
93}
94
95/// Recursively collect (skia_path, skia_color, stroke_width) for each visible
96/// path in the usvg tree.
97fn collect_paths(
98    group: &usvg::Group,
99    draw_stroke_width: f32,
100    out: &mut Vec<(Path, skia_safe::Color, f32)>,
101) {
102    for node in group.children() {
103        match node {
104            usvg::Node::Group(g) => {
105                collect_paths(g, draw_stroke_width, out);
106            }
107            usvg::Node::Path(p) => {
108                if !p.is_visible() {
109                    continue;
110                }
111                let skia_path = tiny_path_to_skia(p.data(), p.abs_transform());
112                // Determine stroke color and width: prefer the SVG stroke; fall
113                // back to the fill color with `draw_stroke_width`.
114                let (color, sw) = if let Some(stroke) = p.stroke() {
115                    let sw = stroke.width().get();
116                    let c = match stroke.paint() {
117                        usvg::Paint::Color(col) => {
118                            let alpha = (stroke.opacity().get() * 255.0) as u8;
119                            skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue)
120                        }
121                        // Gradients/patterns: fall back to white
122                        _ => skia_safe::Color::WHITE,
123                    };
124                    (c, sw)
125                } else if let Some(fill) = p.fill() {
126                    let c = match fill.paint() {
127                        usvg::Paint::Color(col) => {
128                            let alpha = (fill.opacity().get() * 255.0) as u8;
129                            skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue)
130                        }
131                        _ => skia_safe::Color::WHITE,
132                    };
133                    (c, draw_stroke_width)
134                } else {
135                    (skia_safe::Color::WHITE, draw_stroke_width)
136                };
137                out.push((skia_path, color, sw));
138            }
139            // Image, Text and other node kinds are skipped in draw-on mode.
140            _ => {}
141        }
142    }
143}
144
145/// Draw the SVG paths progressively at `draw_progress` (0..=1).
146/// Uses a dash PathEffect to reveal each path sequentially (or with overlap).
147fn paint_draw_on(
148    canvas: &Canvas,
149    group: &usvg::Group,
150    svg_size: usvg::Size,
151    layout: &BoxLayout,
152    progress: f32,
153    draw_stroke_width: f32,
154    draw_overlap: f32,
155) {
156    let progress = progress.clamp(0.0, 1.0);
157
158    // Collect all paths with their colors.
159    let mut paths_with_colors: Vec<(Path, skia_safe::Color, f32)> = Vec::new();
160    collect_paths(group, draw_stroke_width, &mut paths_with_colors);
161
162    if paths_with_colors.is_empty() {
163        return;
164    }
165
166    // Scale canvas from SVG coordinate space to layout box dimensions.
167    let scale_x = if svg_size.width() > 0.0 {
168        layout.width / svg_size.width()
169    } else {
170        1.0
171    };
172    let scale_y = if svg_size.height() > 0.0 {
173        layout.height / svg_size.height()
174    } else {
175        1.0
176    };
177
178    canvas.save();
179    canvas.scale((scale_x, scale_y));
180
181    // Measure path lengths in SVG space (paths already carry the abs_transform).
182    // We measure in SVG space, scaling the lengths to account for the canvas scale.
183    let lengths: Vec<f32> = paths_with_colors
184        .iter()
185        .map(|(path, _, _)| {
186            let mut pm = PathMeasure::new(path, false, None);
187            pm.length()
188        })
189        .collect();
190
191    let total_length: f32 = lengths.iter().sum();
192    if total_length <= 0.0 {
193        canvas.restore();
194        return;
195    }
196
197    // overlap in [0,1]: 0 = sequential, 1 = all parallel.
198    let overlap = draw_overlap.clamp(0.0, 1.0);
199
200    // Each path occupies a window [start_fraction, end_fraction] within [0,1].
201    // Window size for path i (proportional to its length fraction):
202    //   base_fraction[i] = lengths[i] / total_length
203    // With overlap:
204    //   window_size[i] = base_fraction[i] + overlap * (1.0 - base_fraction[i])
205    //                  = base_fraction[i] * (1 - overlap) + overlap
206    // The window start is placed so that at progress=1 all paths are fully drawn:
207    //   start[i] = cumulative_fraction[i] * (1 - overlap)  (cumulative before path i)
208    //   end[i]   = start[i] + window_size[i]
209
210    let mut cumulative = 0.0f32;
211    for ((path, color, sw), length) in paths_with_colors.iter().zip(lengths.iter()) {
212        let base_frac = length / total_length;
213        let window_size = base_frac * (1.0 - overlap) + overlap;
214        let start_frac = cumulative * (1.0 - overlap);
215        cumulative += base_frac;
216
217        // How much of this path is revealed:
218        // local_t = (progress - start_frac) / window_size, clamped to [0,1]
219        let local_t = if window_size > 0.0 {
220            ((progress - start_frac) / window_size).clamp(0.0, 1.0)
221        } else {
222            if progress >= start_frac {
223                1.0
224            } else {
225                0.0
226            }
227        };
228
229        if local_t <= 0.0 {
230            // Nothing yet for this path.
231            continue;
232        }
233
234        let draw_len = length * local_t;
235
236        let mut paint = Paint::default();
237        paint.set_color(*color);
238        paint.set_style(PaintStyle::Stroke);
239        paint.set_stroke_width(*sw);
240        paint.set_anti_alias(true);
241
242        if local_t < 1.0 && draw_len > 0.0 {
243            let remaining = length - draw_len;
244            // Add a tiny epsilon to avoid gap at exact end.
245            let intervals = [draw_len, remaining + 0.01];
246            if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) {
247                paint.set_path_effect(dash);
248            }
249        }
250        // If local_t == 1.0, draw the full path with no dash effect.
251
252        // Suppress scale effect on stroke width: we applied scale on the canvas,
253        // so the stroke width would be magnified. Compensate by dividing.
254        // Actually, skia already does local transform → stroke is in canvas units,
255        // not SVG units. The canvas is scaled by scale_x/scale_y, so the stroke
256        // rendered in canvas (pixel) space will be sw * scale_x. We want sw in
257        // pixel space, so we divide by scale here.
258        // Use the geometric mean for uniform compensation.
259        let scale_avg = (scale_x * scale_y).sqrt();
260        if scale_avg > 0.0 {
261            paint.set_stroke_width(sw / scale_avg);
262        }
263
264        canvas.draw_path(path, &paint);
265    }
266
267    canvas.restore();
268}
269
270impl Painter for Svg {
271    fn paint_content(
272        &self,
273        canvas: &Canvas,
274        layout: &BoxLayout,
275        props: &AnimatedProperties,
276        _ctx: &PaintCtx,
277    ) {
278        let draw_active = self.draw || (props.draw_progress >= 0.0 && props.draw_progress < 1.0);
279
280        if draw_active {
281            // Draw-on mode: walk the usvg tree and trace paths progressively.
282            let progress = if props.draw_progress >= 0.0 {
283                props.draw_progress
284            } else {
285                // draw: true without animation → show complete trace (static)
286                1.0
287            };
288
289            if progress <= 0.0 {
290                return;
291            }
292
293            let svg_data = if let Some(ref src) = self.src {
294                match std::fs::read(src) {
295                    Ok(d) => d,
296                    Err(_) => return,
297                }
298            } else if let Some(ref data) = self.data {
299                data.as_bytes().to_vec()
300            } else {
301                return;
302            };
303
304            let opt = usvg::Options::default();
305            let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else {
306                return;
307            };
308
309            let svg_size = tree.size();
310
311            if progress >= 1.0 {
312                // At completion, fall through to normal resvg render so fills are shown.
313                self.paint_resvg(canvas, layout, &svg_data, &tree, svg_size);
314            } else {
315                paint_draw_on(
316                    canvas,
317                    tree.root(),
318                    svg_size,
319                    layout,
320                    progress,
321                    self.draw_stroke_width,
322                    self.draw_overlap,
323                );
324            }
325        } else {
326            // Normal static mode: use cached resvg rasterization.
327            self.paint_static(canvas, layout);
328        }
329    }
330}
331
332impl Svg {
333    /// Normal static render via cached resvg bitmap.
334    fn paint_static(&self, canvas: &Canvas, layout: &BoxLayout) {
335        let target_w_opt: Option<u32> = if layout.width > 0.0 {
336            Some(layout.width as u32)
337        } else {
338            None
339        };
340        let target_h_opt: Option<u32> = if layout.height > 0.0 {
341            Some(layout.height as u32)
342        } else {
343            None
344        };
345
346        let cache_key = if let Some(ref src) = self.src {
347            format!(
348                "svg:{}:{}x{}",
349                src,
350                target_w_opt.unwrap_or(0),
351                target_h_opt.unwrap_or(0)
352            )
353        } else if let Some(ref data) = self.data {
354            use std::collections::hash_map::DefaultHasher;
355            use std::hash::{Hash, Hasher};
356            let mut hasher = DefaultHasher::new();
357            data.hash(&mut hasher);
358            format!(
359                "svg-inline:{}:{}x{}",
360                hasher.finish(),
361                target_w_opt.unwrap_or(0),
362                target_h_opt.unwrap_or(0)
363            )
364        } else {
365            return;
366        };
367
368        let cache = asset_cache();
369        let img = if let Some(cached) = cache.get(&cache_key) {
370            cached.clone()
371        } else {
372            let svg_data = if let Some(ref src) = self.src {
373                let Ok(data) = std::fs::read(src) else { return };
374                data
375            } else if let Some(ref data) = self.data {
376                data.as_bytes().to_vec()
377            } else {
378                return;
379            };
380
381            let opt = usvg::Options::default();
382            let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else {
383                return;
384            };
385
386            let svg_size = tree.size();
387            let target_w = target_w_opt.unwrap_or(svg_size.width() as u32);
388            let target_h = target_h_opt.unwrap_or(svg_size.height() as u32);
389
390            let Some(mut pixmap) = tiny_skia::Pixmap::new(target_w, target_h) else {
391                return;
392            };
393
394            let scale_x = target_w as f32 / svg_size.width();
395            let scale_y = target_h as f32 / svg_size.height();
396            let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
397
398            resvg::render(&tree, transform, &mut pixmap.as_mut());
399
400            let img_data = skia_safe::Data::new_copy(pixmap.data());
401            let img_info = ImageInfo::new(
402                (target_w as i32, target_h as i32),
403                ColorType::RGBA8888,
404                skia_safe::AlphaType::Premul,
405                None,
406            );
407            let Some(decoded) =
408                skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4)
409            else {
410                return;
411            };
412            cache.insert(cache_key, decoded.clone());
413            decoded
414        };
415
416        let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
417        let paint = Paint::default();
418        canvas.draw_image_rect(img, None, dst, &paint);
419    }
420
421    /// Render via resvg when draw-on completes (progress == 1.0).
422    fn paint_resvg(
423        &self,
424        canvas: &Canvas,
425        layout: &BoxLayout,
426        svg_data: &[u8],
427        tree: &usvg::Tree,
428        svg_size: usvg::Size,
429    ) {
430        let target_w = if layout.width > 0.0 {
431            layout.width as u32
432        } else {
433            svg_size.width() as u32
434        };
435        let target_h = if layout.height > 0.0 {
436            layout.height as u32
437        } else {
438            svg_size.height() as u32
439        };
440
441        if target_w == 0 || target_h == 0 {
442            return;
443        }
444
445        let Some(mut pixmap) = tiny_skia::Pixmap::new(target_w, target_h) else {
446            return;
447        };
448
449        let scale_x = target_w as f32 / svg_size.width();
450        let scale_y = target_h as f32 / svg_size.height();
451        let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
452        resvg::render(tree, transform, &mut pixmap.as_mut());
453
454        let img_data = skia_safe::Data::new_copy(pixmap.data());
455        let img_info = ImageInfo::new(
456            (target_w as i32, target_h as i32),
457            ColorType::RGBA8888,
458            skia_safe::AlphaType::Premul,
459            None,
460        );
461        let Some(img) =
462            skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4)
463        else {
464            return;
465        };
466
467        let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
468        let paint = Paint::default();
469        canvas.draw_image_rect(img, None, dst, &paint);
470
471        let _ = svg_data; // only used to accept the lifetime; tree holds the parsed data
472    }
473}