11use schemars:: JsonSchema ;
22use 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
55use rustmotion_core:: css:: CssStyle ;
66use 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
2842rustmotion_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+
34268impl 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