diff --git a/crates/rustmotion-components/src/arrow.rs b/crates/rustmotion-components/src/arrow.rs index be33a05..0bf507e 100644 --- a/crates/rustmotion-components/src/arrow.rs +++ b/crates/rustmotion-components/src/arrow.rs @@ -175,19 +175,28 @@ impl Arrow { } } - if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { - let mut measure = PathMeasure::new(&path, false, None); - let total_len = measure.length(); - if total_len > 0.0 { - let draw_len = total_len * props.draw_progress.clamp(0.0, 1.0); - let intervals = [draw_len, total_len - draw_len + 0.01]; - if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) { - paint.set_path_effect(dash); - } - } + let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0; + let draw_start = props.draw_start.max(0.0); + let draw_offset = props.draw_offset; + let trimming = draw_start > 0.0 || draw_offset.abs() > 0.0005; + let trim_start = (draw_start + draw_offset).clamp(0.0, 1.0); + let trim_end = if drawing { + (props.draw_progress.clamp(0.0, 1.0) + draw_offset).clamp(0.0, 1.0) + } else { + (1.0 + draw_offset).clamp(0.0, 1.0) + }; + + if trim_end <= trim_start && (drawing || trimming) { + return; } - canvas.draw_path(&path, &paint); + if drawing || trimming { + let trimmed = + rustmotion_core::engine::renderer::trim_path_between(&path, trim_start, trim_end); + canvas.draw_path(&trimmed, &paint); + } else { + canvas.draw_path(&path, &paint); + } let show_arrows = props.draw_progress < 0.0 || props.draw_progress >= 0.95; if show_arrows { @@ -212,3 +221,172 @@ impl Painter for Arrow { self.paint(canvas, props); } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::engine::layout_pass::Insets; + + const W: i32 = 100; + const H: i32 = 100; + + fn test_layout() -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width: W as f32, + height: H as f32, + border: Insets::default(), + padding: Insets::default(), + } + } + + fn test_ctx() -> PaintCtx { + PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + } + } + + fn straight_arrow() -> Arrow { + Arrow { + x1: 10.0, + y1: 50.0, + x2: 90.0, + y2: 50.0, + cp: None, + cp1: None, + cp2: None, + curve: None, + width: 12.0, + color: "#FFFFFF".to_string(), + arrow_end: true, + arrow_start: false, + arrow_size: default_arrow_size(), + dashed: None, + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn render_alpha(arrow: &Arrow, props: &AnimatedProperties) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + arrow.paint_content(surface.canvas(), &test_layout(), props, &test_ctx()); + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (W, H), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (W * H * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (W * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + buf + } + + fn lit_pixel_count(buf: &[u8]) -> usize { + (0..(W * H) as usize) + .filter(|&i| buf[i * 4 + 3] > 10) + .count() + } + + fn alpha_at(buf: &[u8], x: i32, y: i32) -> u8 { + buf[((y * W + x) * 4 + 3) as usize] + } + + #[test] + fn draw_progress_zero_paints_nothing_not_even_a_dot() { + let arrow = straight_arrow(); + let props = AnimatedProperties { + draw_progress: 0.0, + ..AnimatedProperties::default() + }; + let lit = lit_pixel_count(&render_alpha(&arrow, &props)); + assert_eq!( + lit, 0, + "at draw_progress=0 a round-capped zero-length dash must not paint a solid dot, \ + got {lit} lit pixels" + ); + } + + #[test] + fn draw_progress_partial_paints_a_partial_stroke() { + let arrow = straight_arrow(); + let props = AnimatedProperties { + draw_progress: 0.5, + ..AnimatedProperties::default() + }; + let lit = lit_pixel_count(&render_alpha(&arrow, &props)); + assert!( + lit > 0, + "at draw_progress=0.5 the arrow must paint a partial stroke, got {lit} lit pixels" + ); + } + + #[test] + fn draw_start_erases_the_head_of_an_already_finished_arrow() { + let arrow = straight_arrow(); + let props = AnimatedProperties { + draw_start: 0.5, + ..AnimatedProperties::default() + }; + let buf = render_alpha(&arrow, &props); + assert_eq!( + alpha_at(&buf, 20, 50), + 0, + "before a keyframe-driven draw_start=0.5 on a finished arrow must be erased" + ); + assert!( + alpha_at(&buf, 60, 50) > 40, + "after draw_start=0.5 must stay painted" + ); + } + + #[test] + fn draw_offset_marches_the_drawn_window_along_the_path() { + let arrow = straight_arrow(); + let no_offset = render_alpha( + &arrow, + &AnimatedProperties { + draw_progress: 0.3, + ..AnimatedProperties::default() + }, + ); + let offset = render_alpha( + &arrow, + &AnimatedProperties { + draw_progress: 0.3, + draw_offset: 0.5, + ..AnimatedProperties::default() + }, + ); + assert!( + alpha_at(&no_offset, 20, 50) > 40, + "with no offset the window starts at the path's own beginning" + ); + assert_eq!( + alpha_at(&offset, 20, 50), + 0, + "draw_offset=0.5 must march the window forward, leaving the path's start empty" + ); + assert!( + alpha_at(&offset, 70, 50) > 40, + "and paint further along the path instead" + ); + } +} diff --git a/crates/rustmotion-components/src/connector.rs b/crates/rustmotion-components/src/connector.rs index 3e43b29..7fc3a24 100644 --- a/crates/rustmotion-components/src/connector.rs +++ b/crates/rustmotion-components/src/connector.rs @@ -189,19 +189,28 @@ impl Connector { } } - if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { - let mut measure = PathMeasure::new(&path, false, None); - let total_len = measure.length(); - if total_len > 0.0 { - let draw_len = total_len * props.draw_progress.clamp(0.0, 1.0); - let intervals = [draw_len, total_len - draw_len + 0.01]; - if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) { - paint.set_path_effect(dash); - } - } + let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0; + let draw_start = props.draw_start.max(0.0); + let draw_offset = props.draw_offset; + let trimming = draw_start > 0.0 || draw_offset.abs() > 0.0005; + let trim_start = (draw_start + draw_offset).clamp(0.0, 1.0); + let trim_end = if drawing { + (props.draw_progress.clamp(0.0, 1.0) + draw_offset).clamp(0.0, 1.0) + } else { + (1.0 + draw_offset).clamp(0.0, 1.0) + }; + + if trim_end <= trim_start && (drawing || trimming) { + return; } - canvas.draw_path(&path, &paint); + if drawing || trimming { + let trimmed = + rustmotion_core::engine::renderer::trim_path_between(&path, trim_start, trim_end); + canvas.draw_path(&trimmed, &paint); + } else { + canvas.draw_path(&path, &paint); + } let show_arrows = props.draw_progress < 0.0 || props.draw_progress >= 0.95; if show_arrows { @@ -226,3 +235,168 @@ impl Painter for Connector { self.paint(canvas, props); } } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::engine::layout_pass::Insets; + + const W: i32 = 100; + const H: i32 = 100; + + fn test_layout() -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width: W as f32, + height: H as f32, + border: Insets::default(), + padding: Insets::default(), + } + } + + fn test_ctx() -> PaintCtx { + PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + } + } + + fn straight_connector() -> Connector { + Connector { + from: ConnectorPoint { x: 10.0, y: 50.0 }, + to: ConnectorPoint { x: 90.0, y: 50.0 }, + routing: RoutingMode::Straight, + curvature: default_curvature(), + width: 12.0, + color: "#FFFFFF".to_string(), + arrow_end: true, + arrow_start: false, + arrow_size: default_arrow_size(), + dashed: None, + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + } + } + + fn render_alpha(connector: &Connector, props: &AnimatedProperties) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + connector.paint_content(surface.canvas(), &test_layout(), props, &test_ctx()); + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (W, H), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (W * H * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (W * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + buf + } + + fn lit_pixel_count(buf: &[u8]) -> usize { + (0..(W * H) as usize) + .filter(|&i| buf[i * 4 + 3] > 10) + .count() + } + + fn alpha_at(buf: &[u8], x: i32, y: i32) -> u8 { + buf[((y * W + x) * 4 + 3) as usize] + } + + #[test] + fn draw_progress_zero_paints_nothing_not_even_a_dot() { + let connector = straight_connector(); + let props = AnimatedProperties { + draw_progress: 0.0, + ..AnimatedProperties::default() + }; + let lit = lit_pixel_count(&render_alpha(&connector, &props)); + assert_eq!( + lit, 0, + "at draw_progress=0 a round-capped zero-length dash must not paint a solid dot, \ + got {lit} lit pixels" + ); + } + + #[test] + fn draw_progress_partial_paints_a_partial_stroke() { + let connector = straight_connector(); + let props = AnimatedProperties { + draw_progress: 0.5, + ..AnimatedProperties::default() + }; + let lit = lit_pixel_count(&render_alpha(&connector, &props)); + assert!( + lit > 0, + "at draw_progress=0.5 the connector must paint a partial stroke, got {lit} lit pixels" + ); + } + + #[test] + fn draw_start_erases_the_head_of_an_already_finished_connector() { + let connector = straight_connector(); + let props = AnimatedProperties { + draw_start: 0.5, + ..AnimatedProperties::default() + }; + let buf = render_alpha(&connector, &props); + assert_eq!( + alpha_at(&buf, 20, 50), + 0, + "before a keyframe-driven draw_start=0.5 on a finished connector must be erased" + ); + assert!( + alpha_at(&buf, 60, 50) > 40, + "after draw_start=0.5 must stay painted" + ); + } + + #[test] + fn draw_offset_marches_the_drawn_window_along_the_path() { + let connector = straight_connector(); + let no_offset = render_alpha( + &connector, + &AnimatedProperties { + draw_progress: 0.3, + ..AnimatedProperties::default() + }, + ); + let offset = render_alpha( + &connector, + &AnimatedProperties { + draw_progress: 0.3, + draw_offset: 0.5, + ..AnimatedProperties::default() + }, + ); + assert!( + alpha_at(&no_offset, 20, 50) > 40, + "with no offset the window starts at the path's own beginning" + ); + assert_eq!( + alpha_at(&offset, 20, 50), + 0, + "draw_offset=0.5 must march the window forward, leaving the path's start empty" + ); + assert!( + alpha_at(&offset, 70, 50) > 40, + "and paint further along the path instead" + ); + } +} diff --git a/crates/rustmotion-components/src/shape.rs b/crates/rustmotion-components/src/shape.rs index 2b375eb..a733284 100644 --- a/crates/rustmotion-components/src/shape.rs +++ b/crates/rustmotion-components/src/shape.rs @@ -343,7 +343,13 @@ impl Painter for Shape { paint.set_stroke_cap(skia_line_cap(stroke.line_cap)); paint.set_stroke_join(skia_line_join(stroke.line_join)); - let draw_start = resolve_draw_start(&self.draw_start, &clock); + let expr_draw_start = resolve_draw_start(&self.draw_start, &clock); + let draw_start = if props.draw_start >= 0.0 { + props.draw_start.clamp(0.0, 1.0) + } else { + expr_draw_start + }; + let draw_offset = props.draw_offset; let mut trimmed_path = None; if let Some(intervals) = stroke.dashed.as_ref().filter(|v| v.len() >= 2) { @@ -351,27 +357,25 @@ impl Painter for Shape { if let Some(dash) = skia_safe::PathEffect::dash(intervals, phase) { paint.set_path_effect(dash); } - } else if draw_start > 0.0 { - let end = if props.draw_progress >= 0.0 { - props.draw_progress.clamp(0.0, 1.0) - } else { - 1.0 - }; - let base = morphed_path - .clone() - .or_else(|| build_shape_path(shape, 0.0, 0.0, w, h, corner_radius)); - if let Some(path) = base { - trimmed_path = Some(trim_path_between(&path, draw_start, end)); - } - } else if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { - if let Some(path) = build_shape_path(shape, 0.0, 0.0, w, h, corner_radius) { - let mut measure = skia_safe::PathMeasure::new(&path, false, None); - let path_len = measure.length(); - if path_len > 0.0 { - let draw_len = path_len * props.draw_progress.clamp(0.0, 1.0); - let intervals = [draw_len, path_len - draw_len + 0.01]; - if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) { - paint.set_path_effect(dash); + } else { + let drawing = props.draw_progress >= 0.0 && props.draw_progress < 1.0; + let trimming = draw_start > 0.0 || draw_offset.abs() > 0.0005; + if drawing || trimming { + let end = if props.draw_progress >= 0.0 { + props.draw_progress.clamp(0.0, 1.0) + } else { + 1.0 + }; + let trim_start = (draw_start + draw_offset).clamp(0.0, 1.0); + let trim_end = (end + draw_offset).clamp(0.0, 1.0); + if trim_end <= trim_start { + trimmed_path = Some(skia_safe::PathBuilder::new().detach()); + } else { + let base = morphed_path + .clone() + .or_else(|| build_shape_path(shape, 0.0, 0.0, w, h, corner_radius)); + if let Some(path) = base { + trimmed_path = Some(trim_path_between(&path, trim_start, trim_end)); } } } @@ -625,6 +629,64 @@ mod tests { ); } + #[test] + fn a_keyframe_driven_draw_start_is_honored_even_with_no_static_draw_start_field() { + let shape = straight_line("#FF0000"); + let props = AnimatedProperties { + draw_start: 0.5, + draw_progress: 1.0, + ..AnimatedProperties::default() + }; + let out = render(&shape, &props); + + for x in [5, 40, 90] { + assert!( + !is_red(pixel(&out, x, H / 2)), + "x={x} is before a keyframe-driven draw_start=0.5 and must have no ink — \ + keyframes on draw_start must not be a no-op on shape" + ); + } + for x in [110, 150, 195] { + assert!( + is_red(pixel(&out, x, H / 2)), + "x={x} is after the keyframe-driven draw_start=0.5 and must be lit" + ); + } + } + + #[test] + fn draw_offset_marches_the_drawn_window_along_the_path() { + let shape = straight_line("#FF0000"); + let no_offset = render( + &shape, + &AnimatedProperties { + draw_progress: 0.3, + ..AnimatedProperties::default() + }, + ); + let offset = render( + &shape, + &AnimatedProperties { + draw_progress: 0.3, + draw_offset: 0.5, + ..AnimatedProperties::default() + }, + ); + + assert!( + is_red(pixel(&no_offset, 20, H / 2)), + "with no offset the window starts at the path's own beginning" + ); + assert!( + !is_red(pixel(&offset, 20, H / 2)), + "draw_offset=0.5 must march the window forward, leaving the path's start empty" + ); + assert!( + is_red(pixel(&offset, 140, H / 2)), + "and paint further along the path instead" + ); + } + #[test] fn path_morph_interpolates_between_same_structure_paths() { let mut shape = straight_line("#FF0000"); diff --git a/crates/rustmotion-components/src/svg.rs b/crates/rustmotion-components/src/svg.rs index 178052f..31a4167 100644 --- a/crates/rustmotion-components/src/svg.rs +++ b/crates/rustmotion-components/src/svg.rs @@ -9,10 +9,10 @@ use skia_safe::{ }; use rustmotion_core::css::CssStyle; -use rustmotion_core::engine::animator::AnimatedProperties; +use rustmotion_core::engine::animator::{ease, AnimatedProperties}; use rustmotion_core::engine::layout_pass::BoxLayout; -use rustmotion_core::engine::renderer::asset_cache; -use rustmotion_core::schema::TimelineStep; +use rustmotion_core::engine::renderer::{asset_cache, interpolate_path_data}; +use rustmotion_core::schema::{SvgPathMorph, TimelineStep}; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -58,6 +58,11 @@ pub struct Svg { /// unchanged), `fill` sweeps a mask across each path's full painted shape. #[serde(default)] pub reveal: SvgReveal, + /// Animates a single path's `d` attribute inside this inline SVG document + /// between keyframe shapes. Independent of `draw`/`draw_progress`: it + /// changes the geometry that gets drawn, not how much of it is revealed. + #[serde(default)] + pub path_morph: Option, } fn default_draw_stroke_width() -> f32 { @@ -474,31 +479,26 @@ impl Painter for Svg { canvas: &Canvas, layout: &BoxLayout, props: &AnimatedProperties, - _ctx: &PaintCtx, + ctx: &PaintCtx, ) { + if let Some(morph) = &self.path_morph { + self.paint_with_path_morph(canvas, layout, props, ctx, morph); + return; + } + let draw_active = self.draw || (props.draw_progress >= 0.0 && props.draw_progress < 1.0) - || props.draw_start > 0.0; + || props.draw_start > 0.0 + || props.draw_offset.abs() > 0.0005; if draw_active { - let progress = if props.draw_progress >= 0.0 { - props.draw_progress - } else { - 1.0 - }; + let (window_start, window_end) = draw_window(props); - if progress <= 0.0 { + if window_end <= window_start { 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 { + let Some(svg_data) = self.raw_svg_data() else { return; }; @@ -510,7 +510,7 @@ impl Painter for Svg { let svg_size = tree.size(); - if progress >= 1.0 { + if window_end >= 1.0 && window_start <= 0.0 { self.paint_resvg(canvas, layout, &svg_data, &tree, svg_size); } else if self.reveal == SvgReveal::Fill { let Some(full_image) = self.cached_full_image(layout) else { @@ -521,7 +521,7 @@ impl Painter for Svg { tree.root(), svg_size, layout, - progress, + window_end, self.draw_overlap, &full_image, ); @@ -531,8 +531,8 @@ impl Painter for Svg { tree.root(), svg_size, layout, - progress, - props.draw_start.max(0.0), + window_end, + window_start, self.draw_stroke_width, self.draw_overlap, ); @@ -543,7 +543,229 @@ impl Painter for Svg { } } +fn draw_window(props: &AnimatedProperties) -> (f32, f32) { + let progress = if props.draw_progress >= 0.0 { + props.draw_progress.clamp(0.0, 1.0) + } else { + 1.0 + }; + let draw_start = props.draw_start.max(0.0); + let draw_offset = props.draw_offset; + let window_start = (draw_start + draw_offset).clamp(0.0, 1.0); + let window_end = (progress + draw_offset).clamp(0.0, 1.0); + (window_start, window_end) +} + +fn resolve_svg_path_morph(morph: &SvgPathMorph, time: f64) -> Option { + let keyframes = &morph.keyframes; + let first = keyframes.first()?; + if keyframes.len() == 1 { + return Some(first.value.clone()); + } + let last = keyframes.last()?; + let first_time = first.time; + let last_time = last.time; + let span = (last_time - first_time).max(1e-9); + + let sample_time = if morph.repeat && time > first_time { + let elapsed = time - first_time; + let cycle = elapsed.rem_euclid(span); + let forward = elapsed.div_euclid(span) as i64 % 2 == 0; + if morph.yoyo && !forward { + last_time - cycle + } else { + first_time + cycle + } + } else { + time.clamp(first_time, last_time) + }; + + let mut lower = first; + let mut upper = last; + for pair in keyframes.windows(2) { + if sample_time >= pair[0].time && sample_time <= pair[1].time { + lower = &pair[0]; + upper = &pair[1]; + break; + } + } + + if lower.value == upper.value { + return Some(lower.value.clone()); + } + let segment_span = (upper.time - lower.time).max(1e-9); + let local_t = ((sample_time - lower.time) / segment_span).clamp(0.0, 1.0); + let eased = ease(local_t, &morph.easing) as f32; + interpolate_path_data(&lower.value, &upper.value, eased).map(|p| p.to_svg()) +} + +fn find_tag_bounds_for_id(source: &str, target_id: &str) -> Option<(usize, usize)> { + let needle_double = format!("id=\"{target_id}\""); + let needle_single = format!("id='{target_id}'"); + let id_pos = source + .find(&needle_double) + .or_else(|| source.find(&needle_single))?; + let tag_start = source[..id_pos].rfind('<')?; + let tag_end = id_pos + source[id_pos..].find('>')?; + Some((tag_start, tag_end)) +} + +fn find_d_attribute_value(tag: &str) -> Option<(usize, usize)> { + let bytes = tag.as_bytes(); + let mut i = 0; + while i + 2 < bytes.len() { + if bytes[i] == b'd' && bytes[i + 1] == b'=' { + let boundary_ok = i == 0 || bytes[i - 1].is_ascii_whitespace(); + if boundary_ok { + let quote = bytes[i + 2] as char; + if quote == '"' || quote == '\'' { + let value_start = i + 3; + if let Some(rel_end) = tag[value_start..].find(quote) { + return Some((value_start, value_start + rel_end)); + } + } + } + } + i += 1; + } + None +} + +fn patch_path_d(svg_source: &[u8], target: &str, new_d: &str) -> Option> { + let source = std::str::from_utf8(svg_source).ok()?; + let clean_target = target.strip_prefix('#').unwrap_or(target); + let (tag_start, tag_end) = find_tag_bounds_for_id(source, clean_target)?; + let tag = &source[tag_start..=tag_end]; + let (value_start_rel, value_end_rel) = find_d_attribute_value(tag)?; + + let mut patched = String::with_capacity(source.len() + new_d.len()); + patched.push_str(&source[..tag_start]); + patched.push_str(&tag[..value_start_rel]); + patched.push_str(new_d); + patched.push_str(&tag[value_end_rel..]); + patched.push_str(&source[tag_end + 1..]); + Some(patched.into_bytes()) +} + +fn render_svg_to_image( + svg_data: &[u8], + target_w_opt: Option, + target_h_opt: Option, +) -> Option { + let opt = svg_parse_options(); + let tree = usvg::Tree::from_data(svg_data, &opt).ok()?; + warn_on_unresolved_svg_text(svg_data, &tree); + + let svg_size = tree.size(); + let target_w = target_w_opt.unwrap_or(svg_size.width() as u32); + let target_h = target_h_opt.unwrap_or(svg_size.height() as u32); + + let mut pixmap = tiny_skia::Pixmap::new(target_w, target_h)?; + 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, + ); + skia_safe::images::raster_from_data(&img_info, img_data, target_w as usize * 4) +} + impl Svg { + fn raw_svg_data(&self) -> Option> { + if let Some(ref src) = self.src { + std::fs::read(src).ok() + } else { + self.data.as_ref().map(|d| d.as_bytes().to_vec()) + } + } + + fn paint_with_path_morph( + &self, + canvas: &Canvas, + layout: &BoxLayout, + props: &AnimatedProperties, + ctx: &PaintCtx, + morph: &SvgPathMorph, + ) { + let Some(raw) = self.raw_svg_data() else { + return; + }; + let patched = resolve_svg_path_morph(morph, ctx.time) + .and_then(|d| patch_path_d(&raw, &morph.target, &d)) + .unwrap_or(raw); + + let draw_active = self.draw + || (props.draw_progress >= 0.0 && props.draw_progress < 1.0) + || props.draw_start > 0.0 + || props.draw_offset.abs() > 0.0005; + + let target_w_opt = if layout.width > 0.0 { + Some(layout.width as u32) + } else { + None + }; + let target_h_opt = if layout.height > 0.0 { + Some(layout.height as u32) + } else { + None + }; + + if !draw_active { + let Some(img) = render_svg_to_image(&patched, target_w_opt, target_h_opt) else { + return; + }; + let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height); + canvas.draw_image_rect(img, None, dst, &Paint::default()); + return; + } + + let (window_start, window_end) = draw_window(props); + if window_end <= window_start { + return; + } + + let opt = svg_parse_options(); + let Ok(tree) = usvg::Tree::from_data(&patched, &opt) else { + return; + }; + warn_on_unresolved_svg_text(&patched, &tree); + let svg_size = tree.size(); + + if window_end >= 1.0 && window_start <= 0.0 { + self.paint_resvg(canvas, layout, &patched, &tree, svg_size); + } else if self.reveal == SvgReveal::Fill { + let Some(full_image) = render_svg_to_image(&patched, target_w_opt, target_h_opt) else { + return; + }; + paint_fill_reveal( + canvas, + tree.root(), + svg_size, + layout, + window_end, + self.draw_overlap, + &full_image, + ); + } else { + paint_draw_on( + canvas, + tree.root(), + svg_size, + layout, + window_end, + window_start, + self.draw_stroke_width, + self.draw_overlap, + ); + } + } + fn paint_static(&self, canvas: &Canvas, layout: &BoxLayout) { let Some(img) = self.cached_full_image(layout) else { return; @@ -705,6 +927,7 @@ mod tests { draw_stroke_width: default_draw_stroke_width(), draw_overlap: 0.0, reveal: SvgReveal::Fill, + path_morph: None, } } @@ -814,6 +1037,7 @@ mod tests { draw_stroke_width: default_draw_stroke_width(), draw_overlap: 0.0, reveal: SvgReveal::Stroke, + path_morph: None, } } @@ -943,6 +1167,7 @@ mod tests { draw_stroke_width: default_draw_stroke_width(), draw_overlap: 0.0, reveal: SvgReveal::Stroke, + path_morph: None, } } @@ -1090,3 +1315,260 @@ mod tests { ); } } + +#[cfg(test)] +mod path_drawing_regression_tests { + use super::*; + use rustmotion_core::engine::layout_pass::Insets; + use rustmotion_core::schema::{EasingType, SvgPathMorphKeyframe}; + + const SIZE: i32 = 100; + + fn ctx() -> PaintCtx { + PaintCtx { + time: 0.0, + scenario_time: 0.0, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: SIZE as u32, + video_height: SIZE as u32, + stagger_offset: 0.0, + } + } + + fn ctx_at(time: f64) -> PaintCtx { + PaintCtx { time, ..ctx() } + } + + fn layout() -> BoxLayout { + BoxLayout { + x: 0.0, + y: 0.0, + width: SIZE as f32, + height: SIZE as f32, + border: Insets::default(), + padding: Insets::default(), + } + } + + fn horizontal_stroke_svg() -> Svg { + Svg { + src: None, + data: Some( + "\ + " + .to_string(), + ), + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + draw: false, + draw_stroke_width: default_draw_stroke_width(), + draw_overlap: 0.0, + reveal: SvgReveal::Stroke, + path_morph: None, + } + } + + fn read_alpha(surface: &mut skia_safe::Surface) -> Vec { + let info = skia_safe::ImageInfo::new( + (SIZE, SIZE), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (SIZE * SIZE * 4) as usize]; + let ok = surface.read_pixels(&info, &mut buf, (SIZE * 4) as usize, (0, 0)); + assert!(ok, "pixel read should succeed"); + buf + } + + fn render(svg: &Svg, props: &AnimatedProperties) -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).expect("raster surface"); + svg.paint_content(surface.canvas(), &layout(), props, &ctx()); + read_alpha(&mut surface) + } + + fn render_at(svg: &Svg, props: &AnimatedProperties, time: f64) -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).expect("raster surface"); + svg.paint_content(surface.canvas(), &layout(), props, &ctx_at(time)); + read_alpha(&mut surface) + } + + fn alpha_at(buf: &[u8], x: i32, y: i32) -> u8 { + buf[((y * SIZE + x) * 4 + 3) as usize] + } + + #[test] + fn draw_start_erases_the_head_of_an_already_finished_svg_stroke() { + let svg = horizontal_stroke_svg(); + let props = AnimatedProperties { + draw_start: 0.5, + ..AnimatedProperties::default() + }; + let buf = render(&svg, &props); + assert_eq!( + alpha_at(&buf, 20, 50), + 0, + "draw_start=0.5 on a finished (never-animated) svg stroke must erase the head — \ + the paint_resvg branch used to ignore draw_start entirely once progress was \ + treated as finished" + ); + assert!(alpha_at(&buf, 80, 50) > 40, "and leave the tail painted"); + } + + #[test] + fn draw_start_absent_matches_draw_start_zero_on_a_finished_stroke() { + let svg = horizontal_stroke_svg(); + let with_zero = render( + &svg, + &AnimatedProperties { + draw_start: 0.0, + ..AnimatedProperties::default() + }, + ); + let absent = render(&svg, &AnimatedProperties::default()); + assert_eq!( + with_zero, absent, + "draw_start absent and draw_start 0 must both paint the whole finished stroke" + ); + } + + #[test] + fn draw_offset_marches_the_drawn_window_along_the_path() { + let svg = horizontal_stroke_svg(); + let at_start = render( + &svg, + &AnimatedProperties { + draw_progress: 0.3, + ..AnimatedProperties::default() + }, + ); + let shifted = render( + &svg, + &AnimatedProperties { + draw_progress: 0.3, + draw_offset: 0.5, + ..AnimatedProperties::default() + }, + ); + assert!( + alpha_at(&at_start, 20, 50) > 40, + "with no offset, the window starts at the path's own beginning" + ); + assert_eq!( + alpha_at(&shifted, 20, 50), + 0, + "draw_offset=0.5 must march the window forward, leaving the path's start empty" + ); + assert!( + alpha_at(&shifted, 70, 50) > 40, + "and paint further along the path instead" + ); + } + + fn two_path_svg() -> Svg { + Svg { + src: None, + data: Some( + "\ + \ + " + .to_string(), + ), + timing: Default::default(), + style: Default::default(), + timeline: Vec::new(), + stagger: None, + draw: false, + draw_stroke_width: default_draw_stroke_width(), + draw_overlap: 0.0, + reveal: SvgReveal::Stroke, + path_morph: Some(SvgPathMorph { + target: "morphed".to_string(), + keyframes: vec![ + SvgPathMorphKeyframe { + time: 0.0, + value: "M10 90 L20 90".to_string(), + }, + SvgPathMorphKeyframe { + time: 1.0, + value: "M80 90 L90 90".to_string(), + }, + ], + easing: EasingType::Linear, + repeat: false, + yoyo: false, + }), + } + } + + fn red_alpha_at(buf: &[u8], x: i32, y: i32) -> u8 { + let idx = ((y * SIZE + x) * 4) as usize; + if buf[idx] > 150 && buf[idx + 1] < 60 && buf[idx + 2] < 60 { + buf[idx + 3] + } else { + 0 + } + } + + #[test] + fn path_morph_moves_the_targeted_path_and_leaves_the_other_path_alone() { + let svg = two_path_svg(); + let start_buf = render_at(&svg, &AnimatedProperties::default(), 0.0); + let end_buf = render_at(&svg, &AnimatedProperties::default(), 1.0); + + assert!( + red_alpha_at(&start_buf, 15, 90) > 40, + "at t=0 the morphed (red) path must be at its first keyframe position" + ); + assert_eq!( + red_alpha_at(&start_buf, 85, 90), + 0, + "and not yet at its last keyframe position" + ); + assert!( + red_alpha_at(&end_buf, 85, 90) > 40, + "at t=1 the morphed (red) path must have reached its last keyframe position" + ); + assert_eq!( + red_alpha_at(&end_buf, 15, 90), + 0, + "and left its first keyframe position" + ); + } + + #[test] + fn a_target_with_no_matching_id_falls_back_to_the_original_document_instead_of_panicking() { + let mut svg = two_path_svg(); + svg.path_morph = Some(SvgPathMorph { + target: "does-not-exist".to_string(), + keyframes: vec![ + SvgPathMorphKeyframe { + time: 0.0, + value: "M10 90 L20 90".to_string(), + }, + SvgPathMorphKeyframe { + time: 1.0, + value: "M80 90 L90 90".to_string(), + }, + ], + easing: EasingType::Linear, + repeat: false, + yoyo: false, + }); + let buf = render_at(&svg, &AnimatedProperties::default(), 0.5); + assert!( + red_alpha_at(&buf, 15, 90) > 40, + "an unmatched target must render the untouched document rather than panic or \ + blank the frame" + ); + } +} diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index ad765d8..f257a6c 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -646,6 +646,7 @@ pub struct AnimatedProperties { pub rotate_y: f32, pub perspective: f32, pub draw_progress: f32, + pub draw_offset: f32, pub motion_progress: f32, pub clip_path_progress: f32, pub char_animation: Option, @@ -682,6 +683,7 @@ impl Default for AnimatedProperties { rotate_y: 0.0, perspective: -1.0, draw_progress: -1.0, + draw_offset: 0.0, motion_progress: -1.0, clip_path_progress: -1.0, char_animation: None, @@ -776,6 +778,9 @@ impl AnimatedProperties { if other.draw_progress >= 0.0 { self.draw_progress = other.draw_progress; } + if other.draw_offset.abs() > 0.0005 { + self.draw_offset += other.draw_offset; + } if other.motion_progress >= 0.0 { self.motion_progress = other.motion_progress; } @@ -1049,6 +1054,7 @@ fn apply_property(props: &mut AnimatedProperties, property: &str, value: f64) { "rotate_y" => props.rotate_y = value as f32, "perspective" => props.perspective = value as f32, "draw_progress" => props.draw_progress = value as f32, + "draw_offset" => props.draw_offset = value as f32, "motion_progress" => props.motion_progress = value as f32, "clip_path_progress" => props.clip_path_progress = value as f32, _ => {} @@ -1280,6 +1286,7 @@ fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 { "rotate_y" => props.rotate_y as f64, "perspective" => props.perspective as f64, "draw_progress" => props.draw_progress as f64, + "draw_offset" => props.draw_offset as f64, "motion_progress" => props.motion_progress as f64, "clip_path_progress" => props.clip_path_progress as f64, _ => 0.0, @@ -3399,6 +3406,22 @@ mod merge_contract_tests { assert!((props.rotation - 45.0).abs() < 1e-6); } + #[test] + fn draw_offset_adds_across_buckets_like_translation() { + let mut props = bucket(); + props.draw_offset = 0.2; + + let mut other = bucket(); + other.draw_offset = 0.1; + props.merge(&other); + + assert!( + (props.draw_offset - 0.3).abs() < 1e-6, + "got {}", + props.draw_offset + ); + } + #[test] fn a_neutral_value_in_a_composing_property_is_a_no_op_by_arithmetic() { let mut props = bucket(); diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 19766f8..667a3db 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -650,6 +650,7 @@ const KNOWN_MOTION_PROPERTIES: &[&str] = &[ "clip_path_progress", "letter_spacing", "draw_start", + "draw_offset", ]; fn validate_motion_property(value: &str) -> Result<(), E> { @@ -707,6 +708,45 @@ pub struct KeyframesConfig { pub repeat: bool, } +/// Animates the `d` attribute of one `` inside an inline `svg` +/// document between keyframe shapes, the `svg` counterpart to `shape`'s +/// `path_morph`. An SVG document typically holds several paths, unlike +/// `shape`'s single implicit path, so `target` names which one this track +/// drives. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SvgPathMorph { + /// The `id` attribute of the `` element inside the inline SVG + /// document this track drives, with or without a leading `#`. No match + /// inside the document is a validate-time error, not a silent no-op. + pub target: String, + /// At least one keyframe; a single keyframe holds that shape statically. + pub keyframes: Vec, + /// Applied within each segment between two consecutive keyframes. + #[serde(default)] + pub easing: EasingType, + /// Loops back to the first keyframe once `time` passes the last one, + /// instead of holding the last shape forever. + #[serde(default)] + pub repeat: bool, + /// Only with `repeat`: alternates direction each cycle instead of + /// snapping back to the first keyframe. + #[serde(default)] + pub yoyo: bool, +} + +/// One stop in a `SvgPathMorph`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SvgPathMorphKeyframe { + /// Seconds from scene start, like every other keyframe `time` in this + /// schema — not a `0..1` fraction. + pub time: f64, + /// SVG path data (the `d`-attribute mini-language), replacing the + /// target path's own `d` at this keyframe's time. + pub value: String, +} + /// Motion blur configuration. /// /// Ghost nodes are sampled at times `t - i * (shutter / fps) / samples` for @@ -1716,6 +1756,85 @@ mod motion_property_tests { let effect: AnimationEffect = serde_json::from_value(json).unwrap(); assert!(matches!(effect, AnimationEffect::Keyframes(_))); } + + #[test] + fn draw_offset_is_a_recognized_keyframe_animation_property() { + let json = json!({ + "name": "keyframes", + "keyframes": [ + { "property": "draw_offset", "keyframes": [ + { "time": 0.0, "value": 0.0 }, + { "time": 1.0, "value": 1.0 } + ]} + ] + }); + let effect: AnimationEffect = serde_json::from_value(json) + .expect("draw_offset must be a known animation property, not rejected as unknown"); + match effect { + AnimationEffect::Keyframes(cfg) => assert_eq!(cfg.keyframes[0].property, "draw_offset"), + other => panic!("expected Keyframes, got {other:?}"), + } + } + + #[test] + fn draw_offset_is_a_recognized_wiggle_property() { + let json = json!({ + "name": "wiggle", + "property": "draw_offset", + "amplitude": 0.1, + "frequency": 1.0 + }); + let effect: AnimationEffect = serde_json::from_value(json) + .expect("draw_offset must be usable as a wiggle property, not rejected as unknown"); + match effect { + AnimationEffect::Wiggle(cfg) => assert_eq!(cfg.property, "draw_offset"), + other => panic!("expected Wiggle, got {other:?}"), + } + } +} + +#[cfg(test)] +mod svg_path_morph_schema_tests { + use super::*; + use serde_json::json; + + #[test] + fn a_target_and_at_least_one_keyframe_parse() { + let json = json!({ + "target": "#outline", + "keyframes": [ + { "time": 0.0, "value": "M0 0 L10 0" }, + { "time": 1.0, "value": "M0 0 L20 0" } + ] + }); + let morph: SvgPathMorph = serde_json::from_value(json).expect("valid SvgPathMorph JSON"); + assert_eq!(morph.target, "#outline"); + assert_eq!(morph.keyframes.len(), 2); + } + + #[test] + fn a_missing_target_is_rejected_rather_than_silently_matching_nothing() { + let json = json!({ + "keyframes": [ + { "time": 0.0, "value": "M0 0 L10 0" } + ] + }); + serde_json::from_value::(json).expect_err( + "target is required — without it there is no path to select inside the document", + ); + } + + #[test] + fn an_unknown_field_is_rejected_by_name() { + let json = json!({ + "target": "#outline", + "keyframes": [{ "time": 0.0, "value": "M0 0 L10 0" }], + "duratoin": 1.0 + }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field must be rejected, not silently ignored"); + assert!(err.to_string().contains("duratoin"), "got: {err}"); + } } #[cfg(test)] diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index 1ee3c05..75399da 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -192,6 +192,39 @@ fn validate_children( p )); } + if let Some(morph) = &svg.path_morph { + let kfs: Vec<(f64, String)> = morph + .keyframes + .iter() + .map(|k| (k.time, k.value.clone())) + .collect(); + check_path_morph_keyframes(&kfs, &p, "path_morph", errors); + + let source_bytes = if let Some(ref src) = svg.src { + std::fs::read(src).ok() + } else { + svg.data.as_ref().map(|d| d.as_bytes().to_vec()) + }; + let target_found = match source_bytes + .as_deref() + .and_then(|b| std::str::from_utf8(b).ok()) + { + Some(source) => { + let clean = morph.target.strip_prefix('#').unwrap_or(&morph.target); + source.contains(&format!("id=\"{clean}\"")) + || source.contains(&format!("id='{clean}'")) + } + None => true, + }; + if !target_found { + errors.push(format!( + "{}: path_morph.target '{}' does not match any element id in this \ + inline SVG document (path_morph_target_not_found) — the field \ + would silently have no effect.", + p, morph.target + )); + } + } } Component::Icon(icon) => { if let Some((prefix, name)) = icon.icon.split_once(':') { @@ -241,11 +274,46 @@ fn validate_children( } validate_children(&container.children, &p, scene_duration, errors, warnings); } + Component::Shape(shape) => { + if let Some(morph) = &shape.path_morph { + let kfs: Vec<(f64, String)> = morph + .keyframes + .iter() + .map(|k| (k.time, k.value.clone())) + .collect(); + check_path_morph_keyframes(&kfs, &p, "path_morph", errors); + } + } _ => {} } } } +fn check_path_morph_keyframes( + keyframes: &[(f64, String)], + path: &str, + label: &str, + errors: &mut Vec, +) { + for pair in keyframes.windows(2) { + let (t0, v0) = &pair[0]; + let (t1, v1) = &pair[1]; + if v0 == v1 { + continue; + } + if rustmotion::engine::renderer::interpolate_path_data(v0, v1, 0.5).is_none() { + errors.push(format!( + "{path}: {label} keyframe at time {t0} ('{v0}') and keyframe at time {t1} \ + ('{v1}') have different path command structures \ + (path_morph_structure_mismatch) — interpolating a path needs the same command \ + sequence (same M/L/C/Z order and point count) on every keyframe. Rewrite both \ + keyframes with matching commands, or split the animation into segments that \ + each only move an already-shared structure." + )); + } + } +} + fn drives_draw_progress(style: &CssStyle) -> bool { style.animation.iter().any(|effect| match effect { AnimationEffect::DrawIn(_) | AnimationEffect::StrokeReveal(_) => true, @@ -1504,6 +1572,138 @@ mod motion_path_validation_tests { } } +#[cfg(test)] +mod path_morph_validation_tests { + use super::*; + + const TWO_PATH_SVG: &str = "\ + "; + + #[test] + fn a_structure_mismatched_shape_path_morph_is_a_named_validation_error() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "path_morph": { + "keyframes": [ + { "time": 0.0, "value": "M0 0 L10 0 L10 10 Z" }, + { "time": 1.0, "value": "M0 0 L10 0 L10 10 L5 15 L0 10 Z" } + ] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors + .iter() + .any(|e| e.contains("path_morph_structure_mismatch")), + "a structure mismatch between path_morph keyframes must be a named validation \ + error, not just a render-time stderr warning: {errors:?}" + ); + } + + #[test] + fn a_structure_matched_shape_path_morph_has_no_error() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "path_morph": { + "keyframes": [ + { "time": 0.0, "value": "M0 0 L10 0 L10 10 Z" }, + { "time": 1.0, "value": "M0 0 L20 0 L20 20 Z" } + ] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors + .iter() + .all(|e| !e.contains("path_morph_structure_mismatch")), + "matching command structure must not be flagged: {errors:?}" + ); + } + + #[test] + fn a_structure_mismatched_svg_path_morph_is_a_named_validation_error() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "svg", + "data": TWO_PATH_SVG, + "path_morph": { + "target": "p1", + "keyframes": [ + { "time": 0.0, "value": "M10 10 L20 10" }, + { "time": 1.0, "value": "M10 10 L20 10 L20 20" } + ] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors + .iter() + .any(|e| e.contains("path_morph_structure_mismatch")), + "expected a structure-mismatch error for svg path_morph: {errors:?}" + ); + } + + #[test] + fn an_svg_path_morph_target_that_matches_no_id_is_a_named_validation_error() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "svg", + "data": TWO_PATH_SVG, + "path_morph": { + "target": "does-not-exist", + "keyframes": [ + { "time": 0.0, "value": "M10 10 L20 10" }, + { "time": 1.0, "value": "M10 10 L30 10" } + ] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors + .iter() + .any(|e| e.contains("path_morph_target_not_found")), + "an unmatched target must be named, not a silent no-op: {errors:?}" + ); + } + + #[test] + fn an_svg_path_morph_target_that_matches_an_id_has_no_error() { + let child: ChildComponent = serde_json::from_value(serde_json::json!({ + "type": "svg", + "data": TWO_PATH_SVG, + "path_morph": { + "target": "#p1", + "keyframes": [ + { "time": 0.0, "value": "M10 10 L20 10" }, + { "time": 1.0, "value": "M10 10 L30 10" } + ] + } + })) + .unwrap(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + errors.iter().all(|e| !e.contains("path_morph_target")), + "a leading '#' must still match the bare id attribute: {errors:?}" + ); + } +} + #[cfg(test)] mod unresolved_beat_unit_tests { use super::*;