From 7c011a51de1d00ae176ef32912abac445b3e6ec0 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 12 Aug 2026 00:17:39 +0200 Subject: [PATCH] feat(animation): move a component along a path, visibly to the validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of the 41 `AnimationEffect` variants could make a component follow a trajectory. `PathMeasure` was already in the repository — `svg.rs` and `arrow.rs` use it for stroke dash-reveal — so the primitive existed; nothing used it to position. `motion_path` takes SVG path data, parsed with the same `Path::from_svg` call `shapes.rs` already uses for `shape: { "type": "path" }`, and walks it with `PathMeasure::pos_tan`. No third path format was invented. `orient` turns the component along the tangent, with an `orient_offset` for artwork that does not point right at rest. **The position goes through `css.transform`, and that is the point.** `--strict-anim` folds transforms to find overflow, and it only reads `css.transform`. An effect that positioned a component through any other channel would be invisible to it, and a component sailing off-frame along its curve would validate clean. That hole already exists here — `AnimatedProperties` carries `width`, `height` and `font_size` that its own doc calls painter-only, with no CSS bridge — and this does not widen it: `motion_path` writes `translate_x`/`translate_y`/`rotation`, the same fields `orbit` uses, which the existing bridge already translates. Verified by removing the fix and re-running the decisive test, rather than by argument: an out-of-frame path then reported `Valid scenario`. With it, `--strict-anim` names the instant and the transform that does it: bbox: [2046, 700] -> [2102, 756] (viewport: 1920x1080) hint: at t=1.70s (57% of scene), animation transforms (tx=1886, ty=0, …) push the bbox out of the viewport The mid-path test was validated the same way — the implementation was temporarily replaced by a straight lerp between the path's endpoints, which returned 50 where the real curve gives 100 on an L-shaped path. A test that only checked t=0 and t=1 would have passed either way. Coordinates are deltas from the layout position, like `orbit`. `resolve_props_for_effects` receives neither the resolved box nor the viewport — those are computed later in files frozen for this change — so an absolute space was not implementable without widening that boundary. Degenerate paths hold position with zero rotation rather than producing a NaN that would contaminate layout, and a non-positive duration is both floored in the solver and rejected by name in `validate` — the same posture the spring solver took after `mass: 0` produced NaN in round 4. Worth recording: `PathMeasure::pos_tan` happens to return `None` at zero length in this skia-safe version, so the explicit guard is not strictly load-bearing today. It stays because it is the only place the degenerate case is *named* rather than absorbed by non-contractual behaviour. --- .../src/commands/validate_schema.rs | 220 ++++++++- .../tests/motion_path_strict_anim.rs | 210 ++++++++ crates/rustmotion-core/src/css/animation.rs | 36 ++ crates/rustmotion-core/src/engine/animator.rs | 464 +++++++++++++++++- crates/rustmotion-core/src/schema/video.rs | 236 +++++++++ 5 files changed, 1164 insertions(+), 2 deletions(-) create mode 100644 crates/rustmotion-cli/tests/motion_path_strict_anim.rs diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index a998c70..17dd94d 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -5,7 +5,10 @@ use rustmotion::components::{ChildComponent, Component}; use rustmotion::core::css::style::{ Background, BackgroundLayer, BorderRadius, Color, CssStyle, Display as CssDisplay, }; -use rustmotion::schema::{AnimationEffect, CharAnimationTiming, ResolvedScenario, SpringConfig}; +use rustmotion::engine::animator::{motion_path_length, MOTION_PATH_MIN_LENGTH}; +use rustmotion::schema::{ + AnimationEffect, CharAnimationTiming, MotionPathConfig, ResolvedScenario, SpringConfig, +}; pub fn validate_scenario(scenario: &ResolvedScenario) -> (Vec, Vec) { let mut errors = Vec::new(); @@ -181,6 +184,10 @@ fn validate_children( } } } + + if let AnimationEffect::MotionPath(cfg) = effect { + check_motion_path_config(cfg, &p, errors, warnings); + } } } @@ -688,6 +695,59 @@ fn check_spring_config(spring: &SpringConfig, path: &str, errors: &mut Vec, + warnings: &mut Vec, +) { + if cfg.duration <= 0.0 { + errors.push(format!( + "{path}: motion_path.duration must be > 0 (got {}) — a zero or negative duration \ + cannot be mapped to a point along the path", + cfg.duration + )); + } + if let Some(length) = motion_path_length(&cfg.path) { + if length <= MOTION_PATH_MIN_LENGTH { + let orient_note = if cfg.orient { + ", and `orient: true` will have no effect (a tangent is undefined at zero length)" + } else { + "" + }; + warnings.push(format!( + "{path}: motion_path.path '{}' has (near-)zero measured length (a single \ + point, or every segment collapses onto one) — the component will hold still \ + instead of travelling{orient_note}. If this is intentional, ignore this \ + warning; otherwise check for duplicated/typo'd coordinates.", + cfg.path + )); + } + } +} + /// The `time_scale` declared on a container component, if any. fn container_time_scale(component: &Component) -> Option { match component { @@ -774,6 +834,18 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { } } + // A non-looping `motion_path` settles onto the path's end at + // `delay + duration`, exactly like `Keyframes`/`TiltIn` above — + // budget it the same way. A looping one is continuous by nature + // (like `Orbit`), so it has no completion budget. + AnimationEffect::MotionPath(c) => { + if c.repeat { + None + } else { + Some((c.delay, c.duration)) + } + } + // Non-timing effects: continuous by nature, no completion budget. AnimationEffect::Glow(_) | AnimationEffect::Wiggle(_) @@ -1433,3 +1505,149 @@ mod transition_smoothing_tests { ); } } + +#[cfg(test)] +mod motion_path_validation_tests { + use super::*; + + fn motion_path_child(json_animation: serde_json::Value) -> ChildComponent { + serde_json::from_value(serde_json::json!({ + "type": "shape", + "shape": "rect", + "style": { "animation": [json_animation] } + })) + .expect("valid component JSON") + } + + #[test] + fn zero_or_negative_duration_is_an_error() { + for duration in [0.0, -1.0] { + let child = motion_path_child(serde_json::json!({ + "name": "motion_path", + "path": "M0,0 L100,0", + "duration": duration + })); + 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("motion_path.duration must be > 0")), + "duration={duration}: missing error, got errors={errors:?}" + ); + } + } + + #[test] + fn a_normal_traveling_path_has_no_errors_or_warnings_about_itself() { + let child = motion_path_child(serde_json::json!({ + "name": "motion_path", + "path": "M0,0 L100,0", + "duration": 0.6 + })); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + assert!( + warnings.iter().all(|w| !w.contains("motion_path")), + "unexpected motion_path warning on a normal path: {warnings:?}" + ); + } + + // ---- brief's "single point" / "zero length" degenerate cases: legal at + // parse time, but advisory-flagged here since they are very likely a + // typo (constat: this mirrors check_spring_config's posture, but as a + // warning rather than an error — nothing here is unsound to render). ---- + + #[test] + fn a_single_point_path_is_a_warning_not_an_error() { + let child = motion_path_child(serde_json::json!({ + "name": "motion_path", + "path": "M50,50", + "duration": 0.6 + })); + 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("motion_path")), + "a single-point path must not be a hard error: {errors:?}" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("motion_path") && w.contains("measured length")), + "missing zero-length warning: {warnings:?}" + ); + } + + #[test] + fn a_zero_length_path_combined_with_orient_names_that_orient_does_nothing() { + let child = motion_path_child(serde_json::json!({ + "name": "motion_path", + "path": "M10,10 L10,10", + "duration": 0.6, + "orient": true + })); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 4.0, &mut errors, &mut warnings); + assert!( + warnings + .iter() + .any(|w| w.contains("motion_path") && w.contains("orient")), + "expected the warning to call out orient:true doing nothing here: {warnings:?}" + ); + } + + // ---- entrance-budget completion check now covers motion_path too ---- + + #[test] + fn a_non_looping_motion_path_finishing_after_the_scene_is_an_error() { + let child = motion_path_child(serde_json::json!({ + "name": "motion_path", + "path": "M0,0 L100,0", + "delay": 3.5, + "duration": 1.0 + })); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children( + &[child], + "test", + /*scene_duration=*/ 2.0, + &mut errors, + &mut warnings, + ); + assert!( + errors.iter().any(|e| e.contains("animation finishes at")), + "expected the entrance-budget error for delay 3.5 + duration 1.0 > scene 2.0: {errors:?}" + ); + } + + #[test] + fn a_looping_motion_path_has_no_completion_budget() { + let child = motion_path_child(serde_json::json!({ + "name": "motion_path", + "path": "M0,0 L100,0", + "delay": 3.5, + "duration": 1.0, + "loop": true + })); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children( + &[child], + "test", + /*scene_duration=*/ 2.0, + &mut errors, + &mut warnings, + ); + assert!( + errors.iter().all(|e| !e.contains("animation finishes at")), + "a looping motion_path must not be budget-checked, like orbit/wiggle: {errors:?}" + ); + } +} diff --git a/crates/rustmotion-cli/tests/motion_path_strict_anim.rs b/crates/rustmotion-cli/tests/motion_path_strict_anim.rs new file mode 100644 index 0000000..99df072 --- /dev/null +++ b/crates/rustmotion-cli/tests/motion_path_strict_anim.rs @@ -0,0 +1,210 @@ +//! The decisive proof for the `motion_path` animation effect: a component +//! following a path off the edge of the frame must be caught by +//! `rustmotion validate --strict-anim`. +//! +//! Why this is the test that matters most (see the workstream brief): the +//! validator's `--strict-anim` pass (`commands::geometry:: +//! validate_geometry_animated`, folding transforms via +//! `apply_static_node_transform`) only ever looks at `css.transform`. If +//! `motion_path` positioned a component through any other channel — a +//! painter-only field on `AnimatedProperties`, or a bespoke value the CSS +//! bridge (`css::animation::apply_animated_props`) never translates — +//! `--strict-anim` would see nothing, and a component sailing off the +//! viewport while following its curve would pass validation silently. This +//! test is run against the actual compiled `rustmotion` binary (not an +//! internal call into `validate_geometry_animated`) because +//! `rustmotion-cli::commands` is a private module (`mod commands;` in +//! `src/lib.rs`) — the CLI subprocess is the only externally-observable +//! contract for "`--strict-anim` sees this". +//! +//! Three scenarios, each isolating one variable: +//! 1. A `motion_path` that travels far enough to leave the viewport must be +//! flagged, and only under `--strict-anim` (the resting/frame-0 layout is +//! fully inside the frame — only sampling the animated transform catches +//! it, which is the whole point of `--strict-anim`). +//! 2. The same component with a `motion_path` that stays on-screen the +//! whole time must NOT be flagged — ruling out a validator that treats +//! any `motion_path` as suspect rather than actually measuring it. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +/// Minimal RAII scratch file — avoids a `tempfile` dev-dependency for three +/// small fixtures (mirrors `skill_files_match_disk.rs`'s `ScratchDir`). +struct ScratchFile(PathBuf); + +impl ScratchFile { + fn new(label: &str) -> Self { + let unique = format!( + "rustmotion-cli-motion-path-test-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before UNIX epoch") + .as_nanos() + ); + Self(std::env::temp_dir().join(unique)) + } +} + +impl Drop for ScratchFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// A single 100x100 red square, resting well inside a 1920x1080 viewport +/// (x=[200,300], y=[490,590] at rest — both axes comfortably clear of every +/// edge), animated by one `motion_path` effect. `path` is the raw SVG path +/// `d` string; the square starts at its laid-out position (path coordinates +/// are deltas — see `MotionPathConfig`'s doc comment) and slides along +/// `path` over `duration` seconds within a `duration`-second scene (no +/// dead time at the end where sampling would only see the resting-again +/// state). +fn scenario_json(path: &str, duration: f64) -> String { + format!( + r##"{{ + "video": {{ "width": 1920, "height": 1080 }}, + "scenes": [{{ + "duration": {duration}, + "children": [{{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 200, "y": 490, + "style": {{ + "width": "100px", "height": "100px", + "animation": [ + {{ "name": "motion_path", "path": "{path}", "duration": {duration} }} + ] + }}, + "fill": "#ff0000" + }}] + }}] + }}"## + ) +} + +fn write_scenario(scratch: &ScratchFile, json: &str) { + std::fs::write(&scratch.0, json).expect("write scenario fixture"); +} + +fn run_validate(scenario_path: &PathBuf, report_path: &PathBuf, strict_anim: bool) -> Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rustmotion")); + cmd.arg("validate") + .arg("--file") + .arg(scenario_path) + .arg("--report") + .arg(report_path); + if strict_anim { + cmd.arg("--strict-anim"); + } + cmd.output().expect("failed to spawn `rustmotion validate`") +} + +fn animated_text_overflow_count(report_json: &serde_json::Value) -> usize { + report_json["geometry_violations"] + .as_array() + .map(|v| { + v.iter() + .filter(|violation| violation["kind"] == "animated_text_overflow") + .count() + }) + .unwrap_or(0) +} + +/// The decisive test: a `motion_path` sliding a component 2000px to the +/// right over a 2s scene must push it past the 1920px-wide viewport's right +/// edge at some sampled time, and `--strict-anim` must report it as +/// `animated_text_overflow` — the same violation kind the pre-existing +/// `spin`/rotation coverage in `commands::geometry`'s own test suite uses. +#[test] +fn strict_anim_detects_a_motion_path_that_leaves_the_viewport() { + let scenario = ScratchFile::new("overflow-scenario"); + let report = ScratchFile::new("overflow-report"); + write_scenario(&scenario, &scenario_json("M0,0 L3000,0", 2.0)); + + let output = run_validate(&scenario.0, &report.0, /*strict_anim=*/ true); + assert!( + !output.status.success(), + "expected `validate --strict-anim` to fail (block) on an out-of-frame motion_path; \ + stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let report_text = std::fs::read_to_string(&report.0).expect("read report"); + let report_json: serde_json::Value = + serde_json::from_str(&report_text).expect("report is valid JSON"); + let count = animated_text_overflow_count(&report_json); + assert!( + count >= 1, + "expected at least one animated_text_overflow violation, got report: {report_text}" + ); +} + +/// The negative control for the decisive test: the exact same component, +/// same 2000px-off-frame `motion_path`, but validated WITHOUT +/// `--strict-anim`. The component's resting (t=0) box is fully inside the +/// viewport — only sampling the *animated* transform can see the +/// overflow — so this must report clean. If it didn't (i.e. if this also +/// failed), the decisive test above would be meaningless: it would prove +/// only that the static box is flagged, not that `--strict-anim` is doing +/// anything path-specific. +#[test] +fn without_strict_anim_the_same_out_of_frame_motion_path_is_not_caught() { + let scenario = ScratchFile::new("overflow-scenario-no-strict"); + let report = ScratchFile::new("overflow-report-no-strict"); + write_scenario(&scenario, &scenario_json("M0,0 L3000,0", 2.0)); + + let output = run_validate(&scenario.0, &report.0, /*strict_anim=*/ false); + assert!( + output.status.success(), + "the resting layout alone must validate clean (the overflow is animation-only); \ + stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let report_text = std::fs::read_to_string(&report.0).expect("read report"); + let report_json: serde_json::Value = + serde_json::from_str(&report_text).expect("report is valid JSON"); + assert_eq!( + animated_text_overflow_count(&report_json), + 0, + "must not report animated_text_overflow without --strict-anim: {report_text}" + ); +} + +/// The false-positive guard: a `motion_path` that only ever moves the +/// component a few pixels — nowhere near any viewport edge — must validate +/// clean even under `--strict-anim`. Without this, the decisive test above +/// would not distinguish "correctly measures the path" from "flags every +/// motion_path indiscriminately". +#[test] +fn strict_anim_does_not_flag_a_motion_path_that_stays_on_screen() { + let scenario = ScratchFile::new("safe-scenario"); + let report = ScratchFile::new("safe-report"); + // 50px of travel from a resting position 200px clear of the nearest + // (left) edge and >1500px clear of the right edge — nowhere close to + // leaving the 1920-wide viewport at any sampled time. + write_scenario(&scenario, &scenario_json("M0,0 L50,0", 2.0)); + + let output = run_validate(&scenario.0, &report.0, /*strict_anim=*/ true); + assert!( + output.status.success(), + "a motion_path that stays on-screen must validate clean under --strict-anim; \ + stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let report_text = std::fs::read_to_string(&report.0).expect("read report"); + let report_json: serde_json::Value = + serde_json::from_str(&report_text).expect("report is valid JSON"); + assert_eq!( + animated_text_overflow_count(&report_json), + 0, + "on-screen travel must not be flagged: {report_text}" + ); +} diff --git a/crates/rustmotion-core/src/css/animation.rs b/crates/rustmotion-core/src/css/animation.rs index 4601ce4..bf7fe49 100644 --- a/crates/rustmotion-core/src/css/animation.rs +++ b/crates/rustmotion-core/src/css/animation.rs @@ -150,6 +150,42 @@ mod tests { assert!(css.perspective.is_none()); } + /// `motion_path` (`engine::animator::apply_motion_paths`) writes its + /// resolved position into `translate_x`/`translate_y` and its + /// tangent-derived orientation into `rotation` — the exact same fields + /// `orbit`/presets already write. This is the unit-level half of the + /// proof that a `motion_path` excursion reaches `css.transform` (and + /// therefore `--strict-anim`'s viewport check, which folds + /// `css.transform`): shape an `AnimatedProperties` the way that effect + /// would, at a moment where it has both moved *and* turned, and confirm + /// both land in the transform list in the documented translate→rotate + /// order — no new bridge, no separate channel. + #[test] + fn motion_path_shaped_translate_and_rotation_compose_into_one_transform_list() { + let mut css = CssStyle::default(); + let props = AnimatedProperties { + translate_x: 120.0, + translate_y: -40.0, + rotation: 33.5, + ..AnimatedProperties::default() + }; + apply_animated_props(&mut css, &props); + + let tx = css.transform.expect("transform list created"); + assert_eq!(tx.len(), 2, "expected translate + rotate, got {:?}", tx); + match &tx[0] { + TransformFn::Translate { x, y } => { + assert!(matches!(x, LengthPercentage::Px(v) if (*v - 120.0).abs() < 1e-6)); + assert!(matches!(y, LengthPercentage::Px(v) if (*v + 40.0).abs() < 1e-6)); + } + other => panic!("expected Translate first, got {:?}", other), + } + match &tx[1] { + TransformFn::Rotate { deg } => assert!((*deg - 33.5).abs() < 1e-6), + other => panic!("expected Rotate second, got {:?}", other), + } + } + #[test] fn blur_and_glow_compose_into_filter_list() { let mut css = CssStyle::default(); diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index b20a02c..78ec381 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -1,6 +1,7 @@ use crate::schema::{ Animation, AnimationEffect, AnimationPreset, CharAnimPreset, EasingType, GlowConfig, Keyframe, - KeyframeValue, OrbitConfig, PresetConfig, SpringConfig, TextAnimGranularity, WiggleConfig, + KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, SpringConfig, TextAnimGranularity, + WiggleConfig, }; /// Safe division that returns `fallback` when the denominator is too small to @@ -62,6 +63,12 @@ pub struct ExtractedEffects<'a> { pub keyframes_loop: bool, pub wiggles: Vec<&'a WiggleConfig>, pub orbits: Vec<&'a OrbitConfig>, + /// Every `motion_path` effect, resolved by `apply_motion_paths` into + /// `translate_x`/`translate_y` (and, when `orient` is set, + /// `rotation`) — the same additive-into-`props` treatment `orbits` + /// already gets, and for the same reason: multiple path effects on one + /// node compose by simple vector addition, not last-wins. + pub motion_paths: Vec<&'a MotionPathConfig>, pub glow: Option<&'a GlowConfig>, pub motion_blur: Option, pub char_animation: Option, @@ -92,6 +99,7 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { keyframes_loop: false, wiggles: Vec::new(), orbits: Vec::new(), + motion_paths: Vec::new(), glow: None, motion_blur: None, char_animation: None, @@ -184,6 +192,9 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { AnimationEffect::MotionBlur(config) => { result.motion_blur = Some(config.intensity); } + AnimationEffect::MotionPath(config) => { + result.motion_paths.push(config); + } _ => {} // preset variants already handled above } } @@ -773,6 +784,10 @@ pub fn resolve_props_for_effects( let orbits: Vec<_> = extracted.orbits.iter().copied().cloned().collect(); apply_orbits(&mut props, &orbits, time); } + if !extracted.motion_paths.is_empty() { + let motion_paths: Vec<_> = extracted.motion_paths.iter().copied().cloned().collect(); + apply_motion_paths(&mut props, &motion_paths, time); + } if extracted.char_animation.is_some() { props.char_animation = extracted.char_animation; } @@ -1126,6 +1141,181 @@ pub fn apply_orbits(props: &mut AnimatedProperties, orbits: &[OrbitConfig], time } } +// ─── Motion path ──────────────────────────────────────────────────────────── + +/// Below this measured path length (in px), a `motion_path` is treated as +/// the "zero length" degenerate case: the component holds at its single +/// point instead of travelling, and `orient` contributes no rotation (a +/// tangent is undefined at zero length). Not `0.0` exactly — `PathMeasure` +/// is a numeric approximation, and a path whose segments collapse onto one +/// point within float precision (e.g. two near-coincident cubic control +/// points) should degrade the same defined way a literal single-point path +/// does, rather than pass through as a very short, jittery "real" travel. +pub const MOTION_PATH_MIN_LENGTH: f32 = 1e-3; + +/// Parse `path_data` and measure its length, in px — the shared primitive +/// `apply_motion_paths` (render time) and `validate_schema.rs`'s advisory +/// zero-length check (author time) both build on, so the two never +/// disagree about what "degenerate" means. +/// +/// Returns `None` when `path_data` is empty or not valid SVG path data. +/// Every `motion_path` effect reachable through `AnimationEffect` already +/// has this ruled out at JSON-parse time +/// (`schema/video.rs::deserialize_motion_path_data`), so in practice `None` +/// only fires if a caller builds a `MotionPathConfig` directly in Rust, +/// bypassing that gate. `Some(0.0)` (or a value below +/// `MOTION_PATH_MIN_LENGTH`) is returned for a syntactically valid path +/// with (near-)zero measured length — a well-defined, distinct case from +/// "invalid", per `MotionPathConfig`'s "Degenerate paths" doc section. +pub fn motion_path_length(path_data: &str) -> Option { + let path = skia_safe::Path::from_svg(path_data)?; + if path.count_points() == 0 { + return None; + } + let mut measure = skia_safe::PathMeasure::new(&path, false, None); + Some(measure.length()) +} + +/// Progress along a `motion_path` effect's own timeline, already eased, in +/// `[0, 1]`. Mirrors the delay/duration semantics every other timed effect +/// in this file uses: before `delay`, progress is pinned to `0.0` (the path +/// hasn't started — the component sits at the path's start point, the same +/// "hold at the entrance state" every preset already does before its own +/// delay elapses); at/after `delay + duration` it is pinned to `1.0` (holds +/// at the path's end) unless `repeat` wraps it back into `[0, 1)` instead. +/// +/// `safe_div`'s fallback (`1.0`) makes a non-positive `duration` behave as +/// "already complete the instant `delay` elapses" — finite and defined, +/// never a NaN/∞ division — the same belt-and-suspenders posture +/// `spring_value` already takes on its own denominators. +/// `validate_schema.rs::check_motion_path_config` additionally rejects +/// `duration <= 0` as an author-facing error, so this fallback is a second +/// line of defence, not the only one. +fn motion_path_progress(cfg: &MotionPathConfig, time: f64) -> f64 { + let elapsed = time - cfg.delay; + if elapsed <= 0.0 { + return 0.0; + } + let raw = safe_div(elapsed, cfg.duration, 1.0); + let progress = if cfg.repeat { + raw.rem_euclid(1.0) + } else { + raw.clamp(0.0, 1.0) + }; + ease(progress, &cfg.easing) +} + +/// One `motion_path` effect's contribution at `time`: a translate delta (in +/// the component-local coordinate space `MotionPathConfig` documents) and a +/// tangent-derived rotation in degrees (`0.0` when `orient` is unset, or +/// when the path is the zero-length degenerate case). +struct MotionPathSample { + dx: f32, + dy: f32, + angle_deg: f32, +} + +/// Sample a `motion_path` effect at `time`. Never returns a NaN/infinite +/// component, for any input — the three degenerate cases the workstream +/// brief names are each handled explicitly rather than falling through to +/// whatever the underlying float operation happens to produce: +/// +/// - **empty/unparsable path**: `AnimationEffect::MotionPath` cannot carry +/// one past `schema/video.rs::deserialize_motion_path_data`'s parse-time +/// rejection, but this function stays defensive anyway (`(0.0, 0.0, +/// 0.0)`, i.e. no displacement) rather than assuming that gate always ran +/// — e.g. a future direct `MotionPathConfig` construction in Rust code +/// would bypass serde entirely. +/// - **single point** (`"M50,50"`) and **zero-length** (every segment +/// collapses onto one point, e.g. `"M10,10 L10,10"`): both measure to +/// (near-)zero length. Position holds at that single point (read via +/// `Path::get_point(0)`) for the entire timeline; orientation is `0.0` +/// regardless of `orient` — a tangent is undefined at zero length, so +/// `atan2(0.0, 0.0)`'s technically-zero-but-meaningless result is never +/// computed or relied on. +fn motion_path_sample(cfg: &MotionPathConfig, time: f64) -> MotionPathSample { + let zero = MotionPathSample { + dx: 0.0, + dy: 0.0, + angle_deg: 0.0, + }; + let Some(path) = skia_safe::Path::from_svg(&cfg.path) else { + return zero; + }; + if path.count_points() == 0 { + return zero; + } + + let mut measure = skia_safe::PathMeasure::new(&path, false, None); + let length = measure.length(); + + // Verified empirically (not just assumed): `PathMeasure::pos_tan` on a + // zero-length contour returns `None` in this skia-safe build, which the + // `None` arm below would also catch — this early return is kept anyway + // as the one place the degenerate case is *named*, rather than an + // undocumented cross-version PathMeasure behaviour a reader would have + // to intuit, and it skips constructing/querying the measure entirely + // for the single most common degenerate input (a single-point path). + if length <= MOTION_PATH_MIN_LENGTH { + let (x, y) = path.get_point(0).map_or((0.0, 0.0), |p| (p.x, p.y)); + return MotionPathSample { + dx: x, + dy: y, + angle_deg: 0.0, + }; + } + + let progress = motion_path_progress(cfg, time) as f32; + let distance = (length * progress).clamp(0.0, length); + + match measure.pos_tan(distance) { + Some((pos, tangent)) => { + let angle_deg = if cfg.orient { + tangent.y.atan2(tangent.x).to_degrees() + cfg.orient_offset as f32 + } else { + 0.0 + }; + MotionPathSample { + dx: pos.x, + dy: pos.y, + angle_deg, + } + } + // `0 <= distance <= length` on a >0-length path should always + // report a position; if Skia ever declines anyway, hold at the + // path's start rather than let a missing sample surface as a jump + // to the component's untranslated origin or a NaN. + None => { + let (x, y) = path.get_point(0).map_or((0.0, 0.0), |p| (p.x, p.y)); + MotionPathSample { + dx: x, + dy: y, + angle_deg: 0.0, + } + } + } +} + +/// Apply every `motion_path` effect additively to `props.translate_x`/ +/// `translate_y` (and, when `orient` is set, `props.rotation`) — the same +/// treatment `apply_orbits`/`apply_wiggles` already give their own +/// continuous effects, and critically, fields `css::animation:: +/// apply_animated_props` already bridges into `css.transform`'s +/// `translate`/`rotate` functions. That bridge — not a new one — is what +/// makes a `motion_path` excursion past the viewport visible to +/// `--strict-anim` (`rustmotion-cli::commands::geometry:: +/// apply_static_node_transform`, which folds `css.transform` to detect +/// overflow): this function must never write position/orientation anywhere +/// else, or that detection silently stops seeing it. +pub fn apply_motion_paths(props: &mut AnimatedProperties, paths: &[MotionPathConfig], time: f64) { + for cfg in paths { + let sample = motion_path_sample(cfg, time); + props.translate_x += sample.dx; + props.translate_y += sample.dy; + props.rotation += sample.angle_deg; + } +} + fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 { match property { "opacity" => props.opacity as f64, @@ -2808,3 +2998,275 @@ mod spring_duration_tests { ); } } + +#[cfg(test)] +mod motion_path_tests { + use super::*; + + fn cfg(path: &str) -> MotionPathConfig { + MotionPathConfig { + path: path.to_string(), + delay: 0.0, + duration: 1.0, + repeat: false, + orient: false, + orient_offset: 0.0, + easing: EasingType::Linear, + } + } + + // ─── The decisive property: on the curve, not the chord ────────────── + + /// A bent two-segment polyline ("M0,0 L100,0 L100,100", total length + /// 200) makes this trivial to prove without any bezier arithmetic: + /// halfway along the *path* (distance 100) lands exactly on the corner + /// (100, 0). The *chord* between the two endpoints (0,0)→(100,100) has + /// its own midpoint at (50, 50) — a linear interpolation between + /// endpoints (what a buggy "lerp the bounding box" implementation would + /// produce) would land there instead. Asserting the real result is far + /// from (50, 50) and exactly at (100, 0) is what distinguishes "walks + /// the path" from "interpolates the endpoints" — checking only t=0/t=1 + /// bounds would pass either implementation. + #[test] + fn mid_path_progress_lands_on_the_curve_not_on_the_endpoint_chord() { + let c = cfg("M0,0 L100,0 L100,100"); + let sample = motion_path_sample(&c, 0.5); + + assert!( + (sample.dx - 100.0).abs() < 0.5, + "expected dx≈100 (on the path's corner), got {}", + sample.dx + ); + assert!( + (sample.dy - 0.0).abs() < 0.5, + "expected dy≈0 (on the path's corner), got {}", + sample.dy + ); + + let chord_x = 50.0f32; + let chord_y = 50.0f32; + let dist_from_chord_midpoint = + ((sample.dx - chord_x).powi(2) + (sample.dy - chord_y).powi(2)).sqrt(); + assert!( + dist_from_chord_midpoint > 40.0, + "t=0.5 must not land near the endpoint-to-endpoint chord midpoint (50,50) — got \ + ({}, {}), which would also pass under a plain linear-interpolation bug", + sample.dx, + sample.dy + ); + } + + // ─── Endpoints, as a sanity boundary (not the decisive test on its own) ── + + #[test] + fn progress_zero_and_one_land_on_the_paths_own_endpoints() { + let c = cfg("M10,20 L310,20 L310,220"); + let start = motion_path_sample(&c, 0.0); + assert!((start.dx - 10.0).abs() < 0.5 && (start.dy - 20.0).abs() < 0.5); + + let end = motion_path_sample(&c, 1.0); + assert!((end.dx - 310.0).abs() < 0.5 && (end.dy - 220.0).abs() < 0.5); + } + + // ─── Coordinate space: deltas relative to the laid-out position ────── + + /// The path's own coordinates are used literally as the translate delta + /// — not normalized so the path's first point becomes (0,0). A path + /// that starts away from the origin therefore starts the component + /// already displaced by that much, on top of wherever layout placed it. + #[test] + fn path_coordinates_are_used_literally_as_the_translate_delta() { + let c = cfg("M100,50 L300,50"); + let sample = motion_path_sample(&c, 0.0); + assert!( + (sample.dx - 100.0).abs() < 0.5 && (sample.dy - 50.0).abs() < 0.5, + "expected the raw path start point (100, 50) as the delta, got ({}, {})", + sample.dx, + sample.dy + ); + } + + // ─── The channel: translate_x/translate_y/rotation, additive ───────── + + #[test] + fn apply_motion_paths_writes_translate_and_rotation_additively() { + let mut props = AnimatedProperties { + translate_x: 5.0, + translate_y: -5.0, + ..AnimatedProperties::default() + }; + let mut c = cfg("M0,0 L100,0"); + c.orient = true; + apply_motion_paths(&mut props, &[c], 0.0); + + // Path start is (0,0), so translate ends up unchanged from the + // pre-existing (5, -5) contribution — proves this is additive, not + // an overwrite. + assert!((props.translate_x - 5.0).abs() < 0.5); + assert!((props.translate_y - (-5.0)).abs() < 0.5); + // Horizontal rightward tangent ⇒ 0 degrees. + assert!(props.rotation.abs() < 0.5, "got {}", props.rotation); + } + + #[test] + fn orient_false_never_touches_rotation() { + // A vertical segment has a 90°-ish tangent; if `orient` leaked + // through despite being false, rotation would move off 0. + let c = cfg("M0,0 L0,100"); + let mut props = AnimatedProperties::default(); + apply_motion_paths(&mut props, &[c], 0.5); + assert_eq!(props.rotation, 0.0); + } + + #[test] + fn orient_true_rotates_toward_the_tangent_and_offset_is_additive() { + let mut vertical = cfg("M0,0 L0,100"); + vertical.orient = true; + let sample = motion_path_sample(&vertical, 0.5); + // Downward tangent (Skia is Y-down): atan2(1, 0) = 90°. + assert!( + (sample.angle_deg - 90.0).abs() < 1.0, + "got {}", + sample.angle_deg + ); + + let mut with_offset = vertical.clone(); + with_offset.orient_offset = 10.0; + let offset_sample = motion_path_sample(&with_offset, 0.5); + assert!( + (offset_sample.angle_deg - 100.0).abs() < 1.0, + "orient_offset must add on top of the tangent angle, got {}", + offset_sample.angle_deg + ); + } + + // ─── Degenerate cases: defined, finite, never NaN ───────────────────── + + #[test] + fn single_point_path_holds_position_and_never_produces_nan() { + let mut c = cfg("M50,50"); + c.orient = true; + for t in [-1.0, 0.0, 0.3, 0.5, 1.0, 2.0] { + let sample = motion_path_sample(&c, t); + assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite()); + assert!((sample.dx - 50.0).abs() < 0.5 && (sample.dy - 50.0).abs() < 0.5); + assert_eq!( + sample.angle_deg, 0.0, + "orientation is undefined at zero length and must default to 0, not NaN" + ); + } + } + + #[test] + fn coincident_points_zero_length_path_holds_without_nan() { + let mut c = cfg("M10,10 L10,10 L10,10"); + c.orient = true; + let sample = motion_path_sample(&c, 0.5); + assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite()); + assert!((sample.dx - 10.0).abs() < 0.5 && (sample.dy - 10.0).abs() < 0.5); + assert_eq!(sample.angle_deg, 0.0); + } + + #[test] + fn empty_path_data_never_panics_or_produces_nan() { + // Bypasses `deserialize_motion_path_data`'s parse-time rejection on + // purpose (constructed directly in Rust) — the runtime sampler must + // still be safe on its own, defence in depth. + let c = cfg(""); + let sample = motion_path_sample(&c, 0.5); + assert_eq!((sample.dx, sample.dy, sample.angle_deg), (0.0, 0.0, 0.0)); + } + + #[test] + fn unparsable_path_data_never_panics_or_produces_nan() { + let c = cfg("definitely not svg path data"); + let sample = motion_path_sample(&c, 0.5); + assert!(sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite()); + } + + #[test] + fn zero_or_negative_duration_never_produces_nan() { + for duration in [0.0, -1.0, -0.5] { + let mut c = cfg("M0,0 L100,0"); + c.duration = duration; + for t in [0.0, 0.5, 1.0, 5.0] { + let sample = motion_path_sample(&c, t); + assert!( + sample.dx.is_finite() && sample.dy.is_finite() && sample.angle_deg.is_finite(), + "duration={duration} time={t} produced a non-finite sample: dx={} dy={}", + sample.dx, + sample.dy + ); + } + } + } + + #[test] + fn motion_path_length_reports_none_for_empty_or_unparsable_input() { + assert_eq!(motion_path_length(""), None); + assert_eq!(motion_path_length("not a path"), None); + } + + #[test] + fn motion_path_length_reports_near_zero_for_a_single_point() { + let len = motion_path_length("M50,50").expect("single point is a valid, parseable path"); + assert!(len <= MOTION_PATH_MIN_LENGTH, "got {len}"); + } + + #[test] + fn motion_path_length_reports_the_real_length_for_a_real_path() { + let len = motion_path_length("M0,0 L100,0").expect("valid path"); + assert!((len - 100.0).abs() < 0.5, "got {len}"); + } + + // ─── Loop semantics ──────────────────────────────────────────────────── + + #[test] + fn looping_wraps_progress_back_toward_the_start() { + let mut c = cfg("M0,0 L100,0 L100,100"); + c.repeat = true; + c.duration = 1.0; + // 1.5s in, with a 1s loop period, is equivalent to t=0.5 within the + // loop — same corner-of-the-L assertion as the non-looping test. + let sample = motion_path_sample(&c, 1.5); + assert!((sample.dx - 100.0).abs() < 0.5 && (sample.dy - 0.0).abs() < 0.5); + } + + #[test] + fn non_looping_holds_at_the_end_past_delay_plus_duration() { + let c = cfg("M0,0 L100,0 L100,100"); + let at_end = motion_path_sample(&c, 1.0); + let past_end = motion_path_sample(&c, 5.0); + assert_eq!(at_end.dx, past_end.dx); + assert_eq!(at_end.dy, past_end.dy); + } + + // ─── Determinism: same time in, same result out ─────────────────────── + + #[test] + fn sampling_is_deterministic_across_repeated_calls() { + let c = cfg("M0,0 C50,-100 150,-100 200,0"); + let first = motion_path_sample(&c, 0.37); + for _ in 0..25 { + let again = motion_path_sample(&c, 0.37); + assert_eq!(first.dx, again.dx); + assert_eq!(first.dy, again.dy); + assert_eq!(first.angle_deg, again.angle_deg); + } + } + + #[test] + fn resolve_props_for_effects_is_deterministic_and_reaches_translate() { + let effect = AnimationEffect::MotionPath(cfg("M0,0 L400,0")); + let effects = vec![effect]; + let a = resolve_props_for_effects(&effects, 0.5, 1.0); + let b = resolve_props_for_effects(&effects, 0.5, 1.0); + assert_eq!(a.translate_x, b.translate_x); + assert_eq!(a.translate_y, b.translate_y); + assert!( + (a.translate_x - 200.0).abs() < 1.0, + "expected ~halfway along a straight 400px path, got {}", + a.translate_x + ); + } +} diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 0f6b7a0..fe35ade 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -1,5 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use skia_safe::Path as SkiaPath; use super::animation::{Animation, AnimationPreset, EasingType, PresetConfig, SpringConfig}; use super::style::{FontWeight, TextAlign, VerticalAlign}; @@ -98,6 +99,12 @@ pub enum AnimationEffect { /// Temporal trail effect: paints copies of the component at prior times /// with decaying opacity, creating a persistence-of-vision ghost trail. Trail(TrailConfig), + /// Move the component along an SVG path, and — when `orient` is set — + /// rotate it to face the path's tangent direction. See + /// [`MotionPathConfig`]'s doc comment for the path syntax, the + /// coordinate space path points are interpreted in, and how degenerate + /// paths (empty, single-point, zero-length) are handled. + MotionPath(MotionPathConfig), } impl AnimationEffect { @@ -120,6 +127,7 @@ impl AnimationEffect { CharScaleIn(c) | CharFadeIn(c) | CharWave(c) | CharBounce(c) | CharRotateIn(c) | CharSlideUp(c) | CharBlurIn(c) => c.delay += by, Keyframes(c) => c.delay += by, + MotionPath(c) => c.delay += by, Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {} } } @@ -629,6 +637,123 @@ pub struct WiggleConfig { pub mode: Option, } +// --- Motion Path Config --- + +/// Configuration for the `motion_path` animation effect: moves — and, +/// optionally, orients — a component along an SVG path. +/// +/// # Path syntax +/// `path` is the SVG path `d`-attribute mini-language (`M`/`L`/`H`/`V`/`C`/ +/// `S`/`Q`/`T`/`A`/`Z`, absolute or relative) — the exact syntax `shape`'s +/// `ShapeType::Path { data }` already accepts and parses with +/// `skia_safe::Path::from_svg` (`engine/renderer/shapes.rs`). This effect +/// reuses that same call rather than inventing a second path grammar, and +/// measures the parsed path with `skia_safe::PathMeasure` — the exact +/// primitive `svg.rs` (dash-reveal of an SVG document's paths) and +/// `arrow.rs` (dash-reveal of a bezier curve, plus its own tangent-based +/// arrowhead orientation) already use for `draw_progress`. `orient`'s +/// tangent-to-degrees math is the same `atan2(tangent.y, tangent.x)` idiom +/// `arrow.rs::draw_arrowhead` already computes for its arrowhead. +/// +/// # Coordinate space +/// Path coordinates are pixel **deltas relative to wherever CSS layout +/// placed the component absent this effect** — the same convention `orbit` +/// already uses for its circular motion (see `OrbitConfig`/`apply_orbits`), +/// and the only one implementable here: the resolver this effect plugs into +/// (`engine::animator::resolve_props_for_effects`) receives only +/// `(effects, time, scene_duration)` — never the component's resolved +/// layout box or the viewport, which are computed downstream in +/// `paint_pass.rs`/`box_builder.rs`. So `"M0,0 L200,0"` slides the +/// component 200px right of its laid-out position (and back, if the effect +/// loops); `"M100,0 L300,0"` starts the component already displaced 100px +/// right of its laid-out position — a direct, intentional consequence of +/// treating the whole path as a translate delta, not a bug to normalize +/// away. +/// +/// # Degenerate paths +/// - **Empty or syntactically invalid** `path` (e.g. `""`, garbage text) is +/// rejected at JSON-parse time by [`deserialize_motion_path_data`] — a +/// named error, never a silent no-op, mirroring +/// [`deserialize_motion_property`]'s treatment of an unrecognised +/// `wiggle`/`keyframes` property name. +/// - **Zero measured length** (a single point, e.g. `"M50,50"`, or every +/// segment collapsing onto one point) is syntactically valid SVG and is +/// *not* rejected at parse time — it has a well-defined render-time +/// meaning: the component holds at that single point for the whole +/// timeline, and `orient` (if set) contributes no rotation (a tangent is +/// undefined at zero length) instead of propagating a NaN. See +/// `engine::animator::motion_path_sample`. `validate_schema.rs` flags it +/// as a (non-blocking) warning, since it is very likely — but not +/// certainly — an authoring mistake (e.g. duplicated coordinates). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct MotionPathConfig { + /// SVG path data (the `d`-attribute mini-language). Must contain at + /// least one drawable point — an empty or unparsable path is rejected + /// at deserialize time (see [`deserialize_motion_path_data`]). + #[serde(deserialize_with = "deserialize_motion_path_data")] + pub path: String, + /// Delay before travel starts (seconds). Before this, the component + /// sits at the path's start point (progress 0) — the same "hold at the + /// entrance state" behaviour every other preset/effect with a `delay` + /// already has. + #[serde(default)] + pub delay: f64, + /// Time to travel the whole path once (seconds). + #[serde(default = "default_animation_duration")] + pub duration: f64, + /// Loop the traversal continuously instead of holding at the path's end + /// once `delay + duration` has elapsed. + #[serde(default, rename = "loop")] + pub repeat: bool, + /// Rotate the component to face the path's tangent direction (default + /// false — position without orientation, e.g. for content that should + /// stay upright while it moves). + #[serde(default)] + pub orient: bool, + /// Degrees added on top of the tangent-derived rotation, for assets + /// whose drawn "forward" direction is not +X (default 0.0). E.g. an + /// icon drawn pointing up needs `orient_offset: 90`. + #[serde(default)] + pub orient_offset: f64, + /// Easing applied to progress along the path (default linear — constant + /// speed along the curve, the expected default for a hand-authored + /// trajectory; `ease_in`/`ease_out` bunches travel toward one end). + #[serde(default)] + pub easing: EasingType, +} + +/// Reject `motion_path.path` values that cannot produce at least one +/// drawable point — the JSON-authoring analogue of "empty path" from the +/// workstream brief. Unlike [`deserialize_motion_property`], there is no +/// finite alphabet to suggest a correction from: any syntactically valid +/// (even visually nonsensical) SVG path `d` string is accepted, exactly as +/// `shape`'s `ShapeType::Path { data }` already accepts it via the same +/// `skia_safe::Path::from_svg` call — this does not invent a second path +/// grammar. +/// +/// A path that parses but has zero measured *length* (e.g. `"M50,50"`) is +/// deliberately NOT rejected here — see `MotionPathConfig`'s "Degenerate +/// paths" doc section for why, and where that case is instead surfaced (a +/// `validate_schema.rs` warning, not a parse-time error). +fn deserialize_motion_path_data<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + match SkiaPath::from_svg(&s) { + Some(path) if path.count_points() > 0 => Ok(s), + Some(_) => Err(serde::de::Error::custom(format!( + "motion_path.path '{s}' has no drawable point (an empty path has nothing to travel \ + to) — provide at least one command, e.g. \"M0,0 L100,0\"" + ))), + None => Err(serde::de::Error::custom(format!( + "motion_path.path '{s}' is not valid SVG path data (the same 'd'-attribute \ + mini-language shape's type: \"path\" accepts), e.g. \"M0,0 C50,-100 150,-100 200,0\"" + ))), + } +} + // --- Supporting types --- #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -979,3 +1104,114 @@ mod motion_property_tests { assert!(matches!(effect, AnimationEffect::Keyframes(_))); } } + +#[cfg(test)] +mod motion_path_schema_tests { + use super::*; + use serde_json::json; + + #[test] + fn motion_path_deserializes_with_known_fields() { + let json = json!({ + "name": "motion_path", + "path": "M0,0 L100,0 L100,100", + "delay": 0.2, + "duration": 1.5, + "loop": true, + "orient": true, + "orient_offset": 90.0, + "easing": "ease_out" + }); + let effect: AnimationEffect = serde_json::from_value(json).unwrap(); + match effect { + AnimationEffect::MotionPath(cfg) => { + assert_eq!(cfg.path, "M0,0 L100,0 L100,100"); + assert_eq!(cfg.delay, 0.2); + assert_eq!(cfg.duration, 1.5); + assert!(cfg.repeat); + assert!(cfg.orient); + assert_eq!(cfg.orient_offset, 90.0); + assert_eq!(cfg.easing, EasingType::EaseOut); + } + other => panic!("expected MotionPath, got {other:?}"), + } + } + + #[test] + fn motion_path_defaults_match_documented_values() { + let json = json!({ "name": "motion_path", "path": "M0,0 L10,0" }); + let effect: AnimationEffect = serde_json::from_value(json).unwrap(); + match effect { + AnimationEffect::MotionPath(cfg) => { + assert_eq!(cfg.delay, 0.0); + assert_eq!(cfg.duration, default_animation_duration()); + assert!(!cfg.repeat); + assert!(!cfg.orient); + assert_eq!(cfg.orient_offset, 0.0); + assert_eq!(cfg.easing, EasingType::Linear); + } + other => panic!("expected MotionPath, got {other:?}"), + } + } + + // ---- brief's "empty path" degenerate case: rejected at parse time, + // not left to silently produce a no-op or a NaN downstream. ---- + + #[test] + fn motion_path_rejects_an_empty_path_string() { + let json = json!({ "name": "motion_path", "path": "" }); + let err = serde_json::from_value::(json) + .expect_err("an empty motion_path.path must be rejected, not silently accepted"); + assert!(err.to_string().contains("no drawable point"), "got: {err}"); + } + + #[test] + fn motion_path_rejects_unparsable_svg_path_data() { + let json = json!({ "name": "motion_path", "path": "this is not svg path data !!" }); + let err = serde_json::from_value::(json) + .expect_err("garbage path data must be rejected, not silently accepted"); + assert!( + err.to_string().contains("not valid SVG path data"), + "got: {err}" + ); + } + + // A single-point ("zero measured length") path is syntactically valid + // and must NOT be rejected at parse time — see MotionPathConfig's + // "Degenerate paths" doc section; the render-time-defined behaviour is + // covered in `engine::animator`'s tests, and the advisory warning in + // `validate_schema.rs`'s. + #[test] + fn motion_path_accepts_a_single_point_path() { + let json = json!({ "name": "motion_path", "path": "M50,50" }); + let effect: AnimationEffect = serde_json::from_value(json) + .expect("a syntactically valid single-point path must be accepted"); + assert!(matches!(effect, AnimationEffect::MotionPath(_))); + } + + #[test] + fn motion_path_rejects_unknown_fields() { + let json = json!({ "name": "motion_path", "path": "M0,0 L10,0", "detla": 0.2 }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field on motion_path must be rejected, not silently ignored"); + assert!(err.to_string().contains("detla"), "got: {err}"); + } + + #[test] + fn motion_path_shift_delay_shifts_the_configs_own_delay() { + let mut effect = AnimationEffect::MotionPath(MotionPathConfig { + path: "M0,0 L10,0".to_string(), + delay: 0.5, + duration: 1.0, + repeat: false, + orient: false, + orient_offset: 0.0, + easing: EasingType::Linear, + }); + effect.shift_delay(0.25); + match effect { + AnimationEffect::MotionPath(cfg) => assert!((cfg.delay - 0.75).abs() < 1e-9), + other => panic!("expected MotionPath, got {other:?}"), + } + } +}