Skip to content

Commit 264e875

Browse files
authored
feat(svg): progressive path draw-on animation (#60)
Closes #48. usvg→Skia path conversion with sequential length-based scheduling, overlap control, fill-contour tracing, unchanged static path.
1 parent 70741a1 commit 264e875

2 files changed

Lines changed: 643 additions & 2 deletions

File tree

crates/rustmotion-components/src/svg.rs

Lines changed: 346 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use schemars::JsonSchema;
22
use serde::{Deserialize, Serialize};
3-
use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect};
3+
use skia_safe::{Canvas, ColorType, ImageInfo, Matrix, Paint, PaintStyle, Path, PathMeasure, Rect};
44

55
use rustmotion_core::css::CssStyle;
66
use rustmotion_core::engine::animator::AnimatedProperties;
@@ -23,6 +23,20 @@ pub struct Svg {
2323
pub timeline: Vec<TimelineStep>,
2424
#[serde(default)]
2525
pub stagger: Option<f32>,
26+
/// Force draw-on mode even when draw_progress is 1.0 (static draw trace view, no animation needed).
27+
#[serde(default)]
28+
pub draw: bool,
29+
/// Stroke width used when tracing fill-only paths (no stroke in the SVG).
30+
#[serde(default = "default_draw_stroke_width")]
31+
pub draw_stroke_width: f32,
32+
/// Overlap factor between paths during draw-on animation.
33+
/// 0.0 = strictly sequential (default); 1.0 = all paths drawn in parallel.
34+
#[serde(default)]
35+
pub draw_overlap: f32,
36+
}
37+
38+
fn default_draw_stroke_width() -> f32 {
39+
2.0
2640
}
2741

2842
rustmotion_core::impl_traits!(Svg {
@@ -31,14 +45,291 @@ rustmotion_core::impl_traits!(Svg {
3145
Styled => style,
3246
});
3347

48+
// ────────────────────────────────────────────────────────────────────────────
49+
// usvg → Skia path conversion
50+
// ────────────────────────────────────────────────────────────────────────────
51+
52+
/// Convert a `tiny_skia::Path` (from usvg) with an `abs_transform` into a
53+
/// `skia_safe::Path`, applying the transform inline. The resulting path is in
54+
/// SVG-space coordinates (pre-layout-scale); callers apply the layout scale via
55+
/// a canvas save/scale.
56+
fn tiny_path_to_skia(tsp: &tiny_skia::Path, abs_transform: tiny_skia::Transform) -> Path {
57+
// Build the absolute-transform matrix for Skia.
58+
// tiny_skia::Transform { sx, ky, kx, sy, tx, ty } (column-major) → Skia Matrix:
59+
// new_all(scale_x, skew_x, trans_x, skew_y, scale_y, trans_y, pers0, pers1, pers2)
60+
let t = abs_transform;
61+
let matrix = Matrix::new_all(t.sx, t.kx, t.tx, t.ky, t.sy, t.ty, 0.0, 0.0, 1.0);
62+
63+
let mut skia_path = Path::new();
64+
for segment in tsp.segments() {
65+
match segment {
66+
tiny_skia::PathSegment::MoveTo(p) => {
67+
let pt = matrix.map_point((p.x, p.y));
68+
skia_path.move_to(pt);
69+
}
70+
tiny_skia::PathSegment::LineTo(p) => {
71+
let pt = matrix.map_point((p.x, p.y));
72+
skia_path.line_to(pt);
73+
}
74+
tiny_skia::PathSegment::QuadTo(p1, p2) => {
75+
let cp = matrix.map_point((p1.x, p1.y));
76+
let ep = matrix.map_point((p2.x, p2.y));
77+
skia_path.quad_to(cp, ep);
78+
}
79+
tiny_skia::PathSegment::CubicTo(p1, p2, p3) => {
80+
let cp1 = matrix.map_point((p1.x, p1.y));
81+
let cp2 = matrix.map_point((p2.x, p2.y));
82+
let ep = matrix.map_point((p3.x, p3.y));
83+
skia_path.cubic_to(cp1, cp2, ep);
84+
}
85+
tiny_skia::PathSegment::Close => {
86+
skia_path.close();
87+
}
88+
}
89+
}
90+
skia_path
91+
}
92+
93+
/// Recursively collect (skia_path, skia_color, stroke_width) for each visible
94+
/// path in the usvg tree.
95+
fn collect_paths(
96+
group: &usvg::Group,
97+
draw_stroke_width: f32,
98+
out: &mut Vec<(Path, skia_safe::Color, f32)>,
99+
) {
100+
for node in group.children() {
101+
match node {
102+
usvg::Node::Group(g) => {
103+
collect_paths(g, draw_stroke_width, out);
104+
}
105+
usvg::Node::Path(p) => {
106+
if !p.is_visible() {
107+
continue;
108+
}
109+
let skia_path = tiny_path_to_skia(p.data(), p.abs_transform());
110+
// Determine stroke color and width: prefer the SVG stroke; fall
111+
// back to the fill color with `draw_stroke_width`.
112+
let (color, sw) = if let Some(stroke) = p.stroke() {
113+
let sw = stroke.width().get();
114+
let c = match stroke.paint() {
115+
usvg::Paint::Color(col) => {
116+
let alpha = (stroke.opacity().get() * 255.0) as u8;
117+
skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue)
118+
}
119+
// Gradients/patterns: fall back to white
120+
_ => skia_safe::Color::WHITE,
121+
};
122+
(c, sw)
123+
} else if let Some(fill) = p.fill() {
124+
let c = match fill.paint() {
125+
usvg::Paint::Color(col) => {
126+
let alpha = (fill.opacity().get() * 255.0) as u8;
127+
skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue)
128+
}
129+
_ => skia_safe::Color::WHITE,
130+
};
131+
(c, draw_stroke_width)
132+
} else {
133+
(skia_safe::Color::WHITE, draw_stroke_width)
134+
};
135+
out.push((skia_path, color, sw));
136+
}
137+
// Image, Text and other node kinds are skipped in draw-on mode.
138+
_ => {}
139+
}
140+
}
141+
}
142+
143+
/// Draw the SVG paths progressively at `draw_progress` (0..=1).
144+
/// Uses a dash PathEffect to reveal each path sequentially (or with overlap).
145+
fn paint_draw_on(
146+
canvas: &Canvas,
147+
group: &usvg::Group,
148+
svg_size: usvg::Size,
149+
layout: &BoxLayout,
150+
progress: f32,
151+
draw_stroke_width: f32,
152+
draw_overlap: f32,
153+
) {
154+
let progress = progress.clamp(0.0, 1.0);
155+
156+
// Collect all paths with their colors.
157+
let mut paths_with_colors: Vec<(Path, skia_safe::Color, f32)> = Vec::new();
158+
collect_paths(group, draw_stroke_width, &mut paths_with_colors);
159+
160+
if paths_with_colors.is_empty() {
161+
return;
162+
}
163+
164+
// Scale canvas from SVG coordinate space to layout box dimensions.
165+
let scale_x = if svg_size.width() > 0.0 {
166+
layout.width / svg_size.width()
167+
} else {
168+
1.0
169+
};
170+
let scale_y = if svg_size.height() > 0.0 {
171+
layout.height / svg_size.height()
172+
} else {
173+
1.0
174+
};
175+
176+
canvas.save();
177+
canvas.scale((scale_x, scale_y));
178+
179+
// Measure path lengths in SVG space (paths already carry the abs_transform).
180+
// We measure in SVG space, scaling the lengths to account for the canvas scale.
181+
let lengths: Vec<f32> = paths_with_colors
182+
.iter()
183+
.map(|(path, _, _)| {
184+
let mut pm = PathMeasure::new(path, false, None);
185+
pm.length()
186+
})
187+
.collect();
188+
189+
let total_length: f32 = lengths.iter().sum();
190+
if total_length <= 0.0 {
191+
canvas.restore();
192+
return;
193+
}
194+
195+
// overlap in [0,1]: 0 = sequential, 1 = all parallel.
196+
let overlap = draw_overlap.clamp(0.0, 1.0);
197+
198+
// Each path occupies a window [start_fraction, end_fraction] within [0,1].
199+
// Window size for path i (proportional to its length fraction):
200+
// base_fraction[i] = lengths[i] / total_length
201+
// With overlap:
202+
// window_size[i] = base_fraction[i] + overlap * (1.0 - base_fraction[i])
203+
// = base_fraction[i] * (1 - overlap) + overlap
204+
// The window start is placed so that at progress=1 all paths are fully drawn:
205+
// start[i] = cumulative_fraction[i] * (1 - overlap) (cumulative before path i)
206+
// end[i] = start[i] + window_size[i]
207+
208+
let mut cumulative = 0.0f32;
209+
for ((path, color, sw), length) in paths_with_colors.iter().zip(lengths.iter()) {
210+
let base_frac = length / total_length;
211+
let window_size = base_frac * (1.0 - overlap) + overlap;
212+
let start_frac = cumulative * (1.0 - overlap);
213+
cumulative += base_frac;
214+
215+
// How much of this path is revealed:
216+
// local_t = (progress - start_frac) / window_size, clamped to [0,1]
217+
let local_t = if window_size > 0.0 {
218+
((progress - start_frac) / window_size).clamp(0.0, 1.0)
219+
} else {
220+
if progress >= start_frac {
221+
1.0
222+
} else {
223+
0.0
224+
}
225+
};
226+
227+
if local_t <= 0.0 {
228+
// Nothing yet for this path.
229+
continue;
230+
}
231+
232+
let draw_len = length * local_t;
233+
234+
let mut paint = Paint::default();
235+
paint.set_color(*color);
236+
paint.set_style(PaintStyle::Stroke);
237+
paint.set_stroke_width(*sw);
238+
paint.set_anti_alias(true);
239+
240+
if local_t < 1.0 && draw_len > 0.0 {
241+
let remaining = length - draw_len;
242+
// Add a tiny epsilon to avoid gap at exact end.
243+
let intervals = [draw_len, remaining + 0.01];
244+
if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) {
245+
paint.set_path_effect(dash);
246+
}
247+
}
248+
// If local_t == 1.0, draw the full path with no dash effect.
249+
250+
// Suppress scale effect on stroke width: we applied scale on the canvas,
251+
// so the stroke width would be magnified. Compensate by dividing.
252+
// Actually, skia already does local transform → stroke is in canvas units,
253+
// not SVG units. The canvas is scaled by scale_x/scale_y, so the stroke
254+
// rendered in canvas (pixel) space will be sw * scale_x. We want sw in
255+
// pixel space, so we divide by scale here.
256+
// Use the geometric mean for uniform compensation.
257+
let scale_avg = (scale_x * scale_y).sqrt();
258+
if scale_avg > 0.0 {
259+
paint.set_stroke_width(sw / scale_avg);
260+
}
261+
262+
canvas.draw_path(path, &paint);
263+
}
264+
265+
canvas.restore();
266+
}
267+
34268
impl Painter for Svg {
35269
fn paint_content(
36270
&self,
37271
canvas: &Canvas,
38272
layout: &BoxLayout,
39-
_props: &AnimatedProperties,
273+
props: &AnimatedProperties,
40274
_ctx: &PaintCtx,
41275
) {
276+
let draw_active = self.draw || (props.draw_progress >= 0.0 && props.draw_progress < 1.0);
277+
278+
if draw_active {
279+
// Draw-on mode: walk the usvg tree and trace paths progressively.
280+
let progress = if props.draw_progress >= 0.0 {
281+
props.draw_progress
282+
} else {
283+
// draw: true without animation → show complete trace (static)
284+
1.0
285+
};
286+
287+
if progress <= 0.0 {
288+
return;
289+
}
290+
291+
let svg_data = if let Some(ref src) = self.src {
292+
match std::fs::read(src) {
293+
Ok(d) => d,
294+
Err(_) => return,
295+
}
296+
} else if let Some(ref data) = self.data {
297+
data.as_bytes().to_vec()
298+
} else {
299+
return;
300+
};
301+
302+
let opt = usvg::Options::default();
303+
let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else {
304+
return;
305+
};
306+
307+
let svg_size = tree.size();
308+
309+
if progress >= 1.0 {
310+
// At completion, fall through to normal resvg render so fills are shown.
311+
self.paint_resvg(canvas, layout, &svg_data, &tree, svg_size);
312+
} else {
313+
paint_draw_on(
314+
canvas,
315+
tree.root(),
316+
svg_size,
317+
layout,
318+
progress,
319+
self.draw_stroke_width,
320+
self.draw_overlap,
321+
);
322+
}
323+
} else {
324+
// Normal static mode: use cached resvg rasterization.
325+
self.paint_static(canvas, layout);
326+
}
327+
}
328+
}
329+
330+
impl Svg {
331+
/// Normal static render via cached resvg bitmap.
332+
fn paint_static(&self, canvas: &Canvas, layout: &BoxLayout) {
42333
let target_w_opt: Option<u32> = if layout.width > 0.0 {
43334
Some(layout.width as u32)
44335
} else {
@@ -124,4 +415,57 @@ impl Painter for Svg {
124415
let paint = Paint::default();
125416
canvas.draw_image_rect(img, None, dst, &paint);
126417
}
418+
419+
/// Render via resvg when draw-on completes (progress == 1.0).
420+
fn paint_resvg(
421+
&self,
422+
canvas: &Canvas,
423+
layout: &BoxLayout,
424+
svg_data: &[u8],
425+
tree: &usvg::Tree,
426+
svg_size: usvg::Size,
427+
) {
428+
let target_w = if layout.width > 0.0 {
429+
layout.width as u32
430+
} else {
431+
svg_size.width() as u32
432+
};
433+
let target_h = if layout.height > 0.0 {
434+
layout.height as u32
435+
} else {
436+
svg_size.height() as u32
437+
};
438+
439+
if target_w == 0 || target_h == 0 {
440+
return;
441+
}
442+
443+
let Some(mut pixmap) = tiny_skia::Pixmap::new(target_w, target_h) else {
444+
return;
445+
};
446+
447+
let scale_x = target_w as f32 / svg_size.width();
448+
let scale_y = target_h as f32 / svg_size.height();
449+
let transform = tiny_skia::Transform::from_scale(scale_x, scale_y);
450+
resvg::render(tree, transform, &mut pixmap.as_mut());
451+
452+
let img_data = skia_safe::Data::new_copy(pixmap.data());
453+
let img_info = ImageInfo::new(
454+
(target_w as i32, target_h as i32),
455+
ColorType::RGBA8888,
456+
skia_safe::AlphaType::Premul,
457+
None,
458+
);
459+
let Some(img) =
460+
skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4)
461+
else {
462+
return;
463+
};
464+
465+
let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
466+
let paint = Paint::default();
467+
canvas.draw_image_rect(img, None, dst, &paint);
468+
469+
let _ = svg_data; // only used to accept the lifetime; tree holds the parsed data
470+
}
127471
}

0 commit comments

Comments
 (0)