Skip to content

feat(animation): move a component along a path, visibly to the validator - #177

Open
LeadcodeDev wants to merge 1 commit into
mainfrom
feat/path-animation
Open

feat(animation): move a component along a path, visibly to the validator#177
LeadcodeDev wants to merge 1 commit into
mainfrom
feat/path-animation

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes the "path animation and path geometry utilities" gap (High/M).

None of the 41 AnimationEffect variants could make a component follow a trajectory. PathMeasure was already in the repository — svg.rs:184 and arrow.rs:126 use it for stroke dash-reveal — so the primitive existed; nothing used it to position.

The shape

{
"type": "shape", "shape": "circle", "fill": "#F68F2B",
"position": "absolute", "x": 160, "y": 700,
"style": {
"width": "56px", "height": "56px",
"animation": [{
"name": "motion_path",
"path": "M0,0 C300,-320 750,-320 1100,-60 C1300,90 1450,-140 1600,-260",
"delay": 0.2, "duration": 2.4,
"orient": true, "orient_offset": 90,
"easing": "ease_in_out"
}]
}
}

path is SVG path data, parsed with the same Path::from_svg call shapes.rs:88 already uses for shape: { "type": "path" }. No third format was invented. orient turns the component along the tangent; orient_offset handles artwork that does not point right at rest.

The position goes through css.transform, and that is the whole point

--strict-anim folds transforms to find overflow, and it reads css.transform only. An effect positioning a component through any other channel would be invisible to it — a component sailing off-frame along its curve would validate clean.

That hole already exists in this repository: AnimatedProperties carries width, height and font_size that its own doc calls "component-internal, painter-only", with no CSS bridge. This change does not widen it. motion_path writes translate_x / translate_y / rotation — the same fields orbit uses, which the existing bridge already translates.

Proved by removing the fix, not by argument. With apply_motion_paths disabled, the decisive test reported:

expected `validate --strict-anim` to fail on an out-of-frame motion_path
stderr= Valid scenario: 1 scene(s) in 1 view(s)

With it in place, --strict-anim names the instant and the transform responsible:

bbox: [2046, 700] -> [2102, 756] (viewport: 1920x1080)
hint: at t=1.70s (57% of scene), animation transforms (tx=1886, ty=0, sx=1.00, sy=1.00)
push the bbox out of the viewport

Three black-box tests against the compiled binary pin all of it: detected under --strict-anim, not detected without it (so the sampling is what finds it), and no false positive on a path that stays on screen.

The mid-path test was validated the same way

A test checking only t=0 and t=1 would pass even with a straight lerp between endpoints. So the implementation was temporarily replaced by exactly that lerp, on an L-shaped path M0,0 L100,0 L100,100:

expected dx≈100 (on the path's corner), got 50

The test discriminates. It is not decoration.

Coordinates

Deltas from the layout position, like orbit. resolve_props_for_effects receives neither the resolved box nor the viewport — both computed later, in files frozen for this change — so an absolute coordinate space was not implementable without widening that boundary. Stated rather than left for a reader to infer.

Degenerate paths

Empty or unparsable path data is rejected at parse. A single-point or zero-length path holds position with zero rotation. A non-positive duration is floored in the solver and rejected by name in validate — the same two-ended posture the spring solver took after mass: 0 produced a NaN that contaminated layout in round 4.

One honest note: 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 behaviour Skia does not contract.

Verification

  • cargo test --workspace: 30 targets, 1114 tests, 0 failures
  • cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings: clean
  • paint_pass.rs, box_builder.rs and geometry.rs stayed read-only — no need arose

Not covered

  • AnimatedProperties.motion_progress, an existing painter-only field, is deliberately left without a producer: giving it one would widen the very surface this change was told not to widen.
  • No ping-pong / reversed direction on loop.
  • The skills rules do not document the syntax yet.

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.
@LeadcodeDevLeadcodeDev added the enhancement New feature or request label Aug 11, 2026
@LeadcodeDevLeadcodeDev self-assigned this Aug 11, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@LeadcodeDev