diff --git a/crates/rustmotion-components/src/svg.rs b/crates/rustmotion-components/src/svg.rs index 9bdaac9..824e6e6 100644 --- a/crates/rustmotion-components/src/svg.rs +++ b/crates/rustmotion-components/src/svg.rs @@ -1,6 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect}; +use skia_safe::{Canvas, ColorType, ImageInfo, Matrix, Paint, PaintStyle, Path, PathMeasure, Rect}; use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::AnimatedProperties; @@ -23,6 +23,20 @@ pub struct Svg { pub timeline: Vec, #[serde(default)] pub stagger: Option, + /// Force draw-on mode even when draw_progress is 1.0 (static draw trace view, no animation needed). + #[serde(default)] + pub draw: bool, + /// Stroke width used when tracing fill-only paths (no stroke in the SVG). + #[serde(default = "default_draw_stroke_width")] + pub draw_stroke_width: f32, + /// Overlap factor between paths during draw-on animation. + /// 0.0 = strictly sequential (default); 1.0 = all paths drawn in parallel. + #[serde(default)] + pub draw_overlap: f32, +} + +fn default_draw_stroke_width() -> f32 { + 2.0 } rustmotion_core::impl_traits!(Svg { @@ -31,14 +45,291 @@ rustmotion_core::impl_traits!(Svg { Styled => style, }); +// ──────────────────────────────────────────────────────────────────────────── +// usvg → Skia path conversion +// ──────────────────────────────────────────────────────────────────────────── + +/// Convert a `tiny_skia::Path` (from usvg) with an `abs_transform` into a +/// `skia_safe::Path`, applying the transform inline. The resulting path is in +/// SVG-space coordinates (pre-layout-scale); callers apply the layout scale via +/// a canvas save/scale. +fn tiny_path_to_skia(tsp: &tiny_skia::Path, abs_transform: tiny_skia::Transform) -> Path { + // Build the absolute-transform matrix for Skia. + // tiny_skia::Transform { sx, ky, kx, sy, tx, ty } (column-major) → Skia Matrix: + // new_all(scale_x, skew_x, trans_x, skew_y, scale_y, trans_y, pers0, pers1, pers2) + let t = abs_transform; + let matrix = Matrix::new_all(t.sx, t.kx, t.tx, t.ky, t.sy, t.ty, 0.0, 0.0, 1.0); + + let mut skia_path = Path::new(); + for segment in tsp.segments() { + match segment { + tiny_skia::PathSegment::MoveTo(p) => { + let pt = matrix.map_point((p.x, p.y)); + skia_path.move_to(pt); + } + tiny_skia::PathSegment::LineTo(p) => { + let pt = matrix.map_point((p.x, p.y)); + skia_path.line_to(pt); + } + tiny_skia::PathSegment::QuadTo(p1, p2) => { + let cp = matrix.map_point((p1.x, p1.y)); + let ep = matrix.map_point((p2.x, p2.y)); + skia_path.quad_to(cp, ep); + } + tiny_skia::PathSegment::CubicTo(p1, p2, p3) => { + let cp1 = matrix.map_point((p1.x, p1.y)); + let cp2 = matrix.map_point((p2.x, p2.y)); + let ep = matrix.map_point((p3.x, p3.y)); + skia_path.cubic_to(cp1, cp2, ep); + } + tiny_skia::PathSegment::Close => { + skia_path.close(); + } + } + } + skia_path +} + +/// Recursively collect (skia_path, skia_color, stroke_width) for each visible +/// path in the usvg tree. +fn collect_paths( + group: &usvg::Group, + draw_stroke_width: f32, + out: &mut Vec<(Path, skia_safe::Color, f32)>, +) { + for node in group.children() { + match node { + usvg::Node::Group(g) => { + collect_paths(g, draw_stroke_width, out); + } + usvg::Node::Path(p) => { + if !p.is_visible() { + continue; + } + let skia_path = tiny_path_to_skia(p.data(), p.abs_transform()); + // Determine stroke color and width: prefer the SVG stroke; fall + // back to the fill color with `draw_stroke_width`. + let (color, sw) = if let Some(stroke) = p.stroke() { + let sw = stroke.width().get(); + let c = match stroke.paint() { + usvg::Paint::Color(col) => { + let alpha = (stroke.opacity().get() * 255.0) as u8; + skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue) + } + // Gradients/patterns: fall back to white + _ => skia_safe::Color::WHITE, + }; + (c, sw) + } else if let Some(fill) = p.fill() { + let c = match fill.paint() { + usvg::Paint::Color(col) => { + let alpha = (fill.opacity().get() * 255.0) as u8; + skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue) + } + _ => skia_safe::Color::WHITE, + }; + (c, draw_stroke_width) + } else { + (skia_safe::Color::WHITE, draw_stroke_width) + }; + out.push((skia_path, color, sw)); + } + // Image, Text and other node kinds are skipped in draw-on mode. + _ => {} + } + } +} + +/// Draw the SVG paths progressively at `draw_progress` (0..=1). +/// Uses a dash PathEffect to reveal each path sequentially (or with overlap). +fn paint_draw_on( + canvas: &Canvas, + group: &usvg::Group, + svg_size: usvg::Size, + layout: &BoxLayout, + progress: f32, + draw_stroke_width: f32, + draw_overlap: f32, +) { + let progress = progress.clamp(0.0, 1.0); + + // Collect all paths with their colors. + let mut paths_with_colors: Vec<(Path, skia_safe::Color, f32)> = Vec::new(); + collect_paths(group, draw_stroke_width, &mut paths_with_colors); + + if paths_with_colors.is_empty() { + return; + } + + // Scale canvas from SVG coordinate space to layout box dimensions. + let scale_x = if svg_size.width() > 0.0 { + layout.width / svg_size.width() + } else { + 1.0 + }; + let scale_y = if svg_size.height() > 0.0 { + layout.height / svg_size.height() + } else { + 1.0 + }; + + canvas.save(); + canvas.scale((scale_x, scale_y)); + + // Measure path lengths in SVG space (paths already carry the abs_transform). + // We measure in SVG space, scaling the lengths to account for the canvas scale. + let lengths: Vec = paths_with_colors + .iter() + .map(|(path, _, _)| { + let mut pm = PathMeasure::new(path, false, None); + pm.length() + }) + .collect(); + + let total_length: f32 = lengths.iter().sum(); + if total_length <= 0.0 { + canvas.restore(); + return; + } + + // overlap in [0,1]: 0 = sequential, 1 = all parallel. + let overlap = draw_overlap.clamp(0.0, 1.0); + + // Each path occupies a window [start_fraction, end_fraction] within [0,1]. + // Window size for path i (proportional to its length fraction): + // base_fraction[i] = lengths[i] / total_length + // With overlap: + // window_size[i] = base_fraction[i] + overlap * (1.0 - base_fraction[i]) + // = base_fraction[i] * (1 - overlap) + overlap + // The window start is placed so that at progress=1 all paths are fully drawn: + // start[i] = cumulative_fraction[i] * (1 - overlap) (cumulative before path i) + // end[i] = start[i] + window_size[i] + + let mut cumulative = 0.0f32; + for ((path, color, sw), length) in paths_with_colors.iter().zip(lengths.iter()) { + let base_frac = length / total_length; + let window_size = base_frac * (1.0 - overlap) + overlap; + let start_frac = cumulative * (1.0 - overlap); + cumulative += base_frac; + + // How much of this path is revealed: + // local_t = (progress - start_frac) / window_size, clamped to [0,1] + let local_t = if window_size > 0.0 { + ((progress - start_frac) / window_size).clamp(0.0, 1.0) + } else { + if progress >= start_frac { + 1.0 + } else { + 0.0 + } + }; + + if local_t <= 0.0 { + // Nothing yet for this path. + continue; + } + + let draw_len = length * local_t; + + let mut paint = Paint::default(); + paint.set_color(*color); + paint.set_style(PaintStyle::Stroke); + paint.set_stroke_width(*sw); + paint.set_anti_alias(true); + + if local_t < 1.0 && draw_len > 0.0 { + let remaining = length - draw_len; + // Add a tiny epsilon to avoid gap at exact end. + let intervals = [draw_len, remaining + 0.01]; + if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) { + paint.set_path_effect(dash); + } + } + // If local_t == 1.0, draw the full path with no dash effect. + + // Suppress scale effect on stroke width: we applied scale on the canvas, + // so the stroke width would be magnified. Compensate by dividing. + // Actually, skia already does local transform → stroke is in canvas units, + // not SVG units. The canvas is scaled by scale_x/scale_y, so the stroke + // rendered in canvas (pixel) space will be sw * scale_x. We want sw in + // pixel space, so we divide by scale here. + // Use the geometric mean for uniform compensation. + let scale_avg = (scale_x * scale_y).sqrt(); + if scale_avg > 0.0 { + paint.set_stroke_width(sw / scale_avg); + } + + canvas.draw_path(path, &paint); + } + + canvas.restore(); +} + impl Painter for Svg { fn paint_content( &self, canvas: &Canvas, layout: &BoxLayout, - _props: &AnimatedProperties, + props: &AnimatedProperties, _ctx: &PaintCtx, ) { + let draw_active = self.draw || (props.draw_progress >= 0.0 && props.draw_progress < 1.0); + + if draw_active { + // Draw-on mode: walk the usvg tree and trace paths progressively. + let progress = if props.draw_progress >= 0.0 { + props.draw_progress + } else { + // draw: true without animation → show complete trace (static) + 1.0 + }; + + if progress <= 0.0 { + return; + } + + let svg_data = if let Some(ref src) = self.src { + match std::fs::read(src) { + Ok(d) => d, + Err(_) => return, + } + } else if let Some(ref data) = self.data { + data.as_bytes().to_vec() + } else { + return; + }; + + let opt = usvg::Options::default(); + let Ok(tree) = usvg::Tree::from_data(&svg_data, &opt) else { + return; + }; + + let svg_size = tree.size(); + + if progress >= 1.0 { + // At completion, fall through to normal resvg render so fills are shown. + self.paint_resvg(canvas, layout, &svg_data, &tree, svg_size); + } else { + paint_draw_on( + canvas, + tree.root(), + svg_size, + layout, + progress, + self.draw_stroke_width, + self.draw_overlap, + ); + } + } else { + // Normal static mode: use cached resvg rasterization. + self.paint_static(canvas, layout); + } + } +} + +impl Svg { + /// Normal static render via cached resvg bitmap. + fn paint_static(&self, canvas: &Canvas, layout: &BoxLayout) { let target_w_opt: Option = if layout.width > 0.0 { Some(layout.width as u32) } else { @@ -124,4 +415,57 @@ impl Painter for Svg { let paint = Paint::default(); canvas.draw_image_rect(img, None, dst, &paint); } + + /// Render via resvg when draw-on completes (progress == 1.0). + fn paint_resvg( + &self, + canvas: &Canvas, + layout: &BoxLayout, + svg_data: &[u8], + tree: &usvg::Tree, + svg_size: usvg::Size, + ) { + let target_w = if layout.width > 0.0 { + layout.width as u32 + } else { + svg_size.width() as u32 + }; + let target_h = if layout.height > 0.0 { + layout.height as u32 + } else { + svg_size.height() as u32 + }; + + if target_w == 0 || target_h == 0 { + return; + } + + let Some(mut pixmap) = tiny_skia::Pixmap::new(target_w, target_h) else { + return; + }; + + let scale_x = target_w as f32 / svg_size.width(); + let scale_y = target_h as f32 / svg_size.height(); + let transform = tiny_skia::Transform::from_scale(scale_x, scale_y); + resvg::render(tree, transform, &mut pixmap.as_mut()); + + let img_data = skia_safe::Data::new_copy(pixmap.data()); + let img_info = ImageInfo::new( + (target_w as i32, target_h as i32), + ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + let Some(img) = + skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4) + else { + return; + }; + + let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); + let paint = Paint::default(); + canvas.draw_image_rect(img, None, dst, &paint); + + let _ = svg_data; // only used to accept the lifetime; tree holds the parsed data + } } diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 2c18366..f63f1e8 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -1119,6 +1119,303 @@ mod component_smoke { } } +// ────────────────────────────────────────────────────────────────────────────── +// SVG draw-on (draw_progress) pixel tests +// ────────────────────────────────────────────────────────────────────────────── +// +// The SVG under test contains two horizontal strokes spatially separated: +// - path 1: a line from (10,30) to (90,30) → top half of the 100×100 box +// - path 2: a line from (10,70) to (90,70) → bottom half of the 100×100 box +// +// Both paths have an explicit stroke (white, width 4) so the draw-on mode +// picks up the SVG stroke color rather than the fill-fallback. +// +// The component is rendered in a 100×100 pixel canvas. +#[cfg(test)] +mod svg_draw_on_tests { + use crate::components::{ChildComponent, Component, PositionMode}; + use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; + use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; + use rustmotion_core::css::taffy_bridge::ConversionContext; + use rustmotion_core::engine::layout_pass::run_layout; + use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; + + // SVG with two horizontal stroked paths, vertically separated. + // Path 1 at y=30 (top region), Path 2 at y=70 (bottom region). + const TWO_STROKE_SVG: &str = r#" + + +"#; + + // SVG with two filled rects (no stroke) — draw mode should trace their contour. + const TWO_FILL_SVG: &str = r#" + + +"#; + + /// Render an SVG component with the given JSON fields at the specified + /// draw_progress (via animation) and return the raw RGBA8888 pixels. + fn render_svg_at(svg_data: &str, extra_fields: serde_json::Value, progress: f64) -> Vec { + let mut json = serde_json::json!({ + "type": "svg", + "data": svg_data, + "style": { "width": "100px", "height": "100px" } + }); + // Merge extra_fields + if let serde_json::Value::Object(map) = extra_fields { + for (k, v) in map { + json[k] = v; + } + } + let component: Component = serde_json::from_value(json).expect("svg deserialize"); + let child = ChildComponent { + component, + position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), + x: None, + y: None, + z_index: None, + }; + let scene = vec![child]; + + let w = 100u32; + let h = 100u32; + let scene_duration = 1.0f64; + + let mut surface = + skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).expect("surface"); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + + let built = build_scene_with_anim( + &scene, + (w as f32, h as f32), + BuildAnimationCtx { + time: progress, + scene_duration, + }, + ); + let layout = run_layout( + &built.root, + (w as f32, h as f32), + &ConversionContext::default(), + ); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); + let frame = PaintFrame { + time: progress, + frame_index: (progress * 30.0) as u32, + fps: 30, + video_width: w, + video_height: h, + scene_duration, + }; + paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); + + let row_bytes = w as usize * 4; + let mut pixels = vec![0u8; row_bytes * h as usize]; + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + pixels + } + + /// Count non-transparent pixels in RGBA buffer. + fn lit(buf: &[u8]) -> usize { + buf.chunks_exact(4).filter(|p| p[3] > 0).count() + } + + /// Count non-transparent pixels in horizontal band [y0, y1) of a 100-wide canvas. + fn lit_band(buf: &[u8], y0: usize, y1: usize) -> usize { + (y0..y1) + .flat_map(|y| (0..100usize).map(move |x| (y * 100 + x) * 4)) + .filter(|&i| buf[i + 3] > 0) + .count() + } + + // ── T1: draw_progress=0 → zero pixels ──────────────────────────────────── + #[test] + fn draw_progress_zero_paints_nothing() { + // No animation configured but draw_progress is forced to 0 via anim at t=0. + // We use a draw_in animation so progress maps to draw_progress. + let buf = render_svg_at( + TWO_STROKE_SVG, + serde_json::json!({ + "style": { + "width": "100px", + "height": "100px", + "animation": [{ "name": "draw_in", "duration": 1.0 }] + } + }), + // t=0: draw_progress animates from 0.0 at delay=0. + 0.0, + ); + assert_eq!( + lit(&buf), + 0, + "draw_progress=0 must produce zero lit pixels (got {})", + lit(&buf) + ); + } + + // ── T2: draw_progress=0.5 → only first path visible (overlap=0) ────────── + #[test] + fn draw_progress_half_shows_first_path_only() { + // Two equal-length strokes → at progress=0.5 the first is fully drawn, + // the second has not started (sequential, overlap=0). + let buf = render_svg_at( + TWO_STROKE_SVG, + serde_json::json!({ + "draw_overlap": 0.0, + "style": { + "width": "100px", + "height": "100px", + "animation": [{ "name": "draw_in", "duration": 1.0 }] + } + }), + 0.5, + ); + // Path 1 is near y=30, path 2 near y=70. + let top = lit_band(&buf, 25, 35); + let bot = lit_band(&buf, 65, 75); + assert!( + top > 0, + "first path (y≈30) must be visible at draw_progress≈0.5 (top={top})" + ); + assert_eq!( + bot, 0, + "second path (y≈70) must be absent at draw_progress≈0.5 (bot={bot})" + ); + } + + // ── T3: draw_progress=1.0 → resvg full render (filled pixels) ─────────── + #[test] + fn draw_progress_one_renders_complete_svg() { + // At progress=1.0 we fall through to resvg so fills are visible. + // TWO_STROKE_SVG only has strokes — just assert both bands are lit. + let buf = render_svg_at( + TWO_STROKE_SVG, + serde_json::json!({ + "style": { + "width": "100px", + "height": "100px", + "animation": [{ "name": "draw_in", "duration": 1.0 }] + } + }), + // At t slightly before 1.0 draw_progress may still be <1.0. + // Use t=1.0 (end of 1s animation) to get progress=1.0. + 1.0, + ); + let top = lit_band(&buf, 25, 35); + let bot = lit_band(&buf, 65, 75); + assert!( + top > 0, + "top band must be lit at draw_progress=1 (top={top})" + ); + assert!( + bot > 0, + "bot band must be lit at draw_progress=1 (bot={bot})" + ); + } + + // ── T4: draw_overlap=1.0 → both paths drawn in parallel at 0.5 ────────── + #[test] + fn draw_overlap_one_draws_all_paths_in_parallel() { + // With overlap=1.0 every path's window spans the full [0,1] range. + // At progress=0.5, both paths are 50% drawn. + let buf = render_svg_at( + TWO_STROKE_SVG, + serde_json::json!({ + "draw_overlap": 1.0, + "style": { + "width": "100px", + "height": "100px", + "animation": [{ "name": "draw_in", "duration": 1.0 }] + } + }), + 0.5, + ); + let top = lit_band(&buf, 25, 35); + let bot = lit_band(&buf, 65, 75); + assert!( + top > 0, + "with overlap=1 both paths must be partially drawn at 0.5; top={top}" + ); + assert!( + bot > 0, + "with overlap=1 both paths must be partially drawn at 0.5; bot={bot}" + ); + } + + // ── T5: fill-only SVG → contour visible in draw mode ───────────────────── + #[test] + fn fill_only_svg_draws_contour_in_draw_mode() { + // TWO_FILL_SVG has no stroke; draw mode should trace the contour using + // the fill color and draw_stroke_width. + let buf = render_svg_at( + TWO_FILL_SVG, + serde_json::json!({ + "draw_stroke_width": 3.0, + "draw_overlap": 0.0, + "style": { + "width": "100px", + "height": "100px", + "animation": [{ "name": "draw_in", "duration": 1.0 }] + } + }), + // At t=0.5, at least some pixels should be visible (first rect partially drawn). + 0.5, + ); + assert!( + lit(&buf) > 0, + "fill-only SVG should produce lit pixels in draw mode at 0.5 (got {})", + lit(&buf) + ); + } + + // ── T6: static render (no draw, no animation) → resvg bitmap unchanged ─── + #[test] + fn static_svg_renders_without_draw_mode() { + // A simple filled-rect SVG with no draw fields. The resvg path must + // produce filled pixels in the fill color. + let filled_svg = r#" + +"#; + let buf = render_svg_at( + filled_svg, + serde_json::json!({}), // no extra fields → static resvg path + 0.5, + ); + // resvg fills the rect red. Count red-dominant pixels. + let red_pixels = buf + .chunks_exact(4) + .filter(|p| p[3] > 0 && p[0] > p[2] && p[0] > p[1]) + .count(); + assert!( + red_pixels > 3000, + "static render must produce many red-dominant pixels (got {red_pixels})" + ); + } + + // ── T7: draw: true → static draw trace visible without animation ────────── + #[test] + fn draw_true_shows_full_trace_without_animation() { + // draw: true forces draw-on mode with progress=1.0 when no animation is active. + // That should fall through to the resvg render (showing the complete SVG). + let buf = render_svg_at( + TWO_STROKE_SVG, + serde_json::json!({ "draw": true }), + 0.5, // time doesn't matter, no animation + ); + let top = lit_band(&buf, 25, 35); + let bot = lit_band(&buf, 65, 75); + assert!(top > 0, "draw:true must show top path (top={top})"); + assert!(bot > 0, "draw:true must show bot path (bot={bot})"); + } +} + #[cfg(test)] mod audio_tests { use std::sync::Arc;