Skip to content

make trajectory playback reusable - #922

Open
Devin T. Currie (DTCurrie) wants to merge 4 commits into
fix/replayer-snapshot-keysfrom
refactor/shared-trajectory-player
Open

make trajectory playback reusable#922
Devin T. Currie (DTCurrie) wants to merge 4 commits into
fix/replayer-snapshot-keysfrom
refactor/shared-trajectory-player

Conversation

@DTCurrie

@DTCurrie Devin T. Currie (DTCurrie) commented Aug 6, 2026

Copy link
Copy Markdown
Member

Splits trajectory playback out of the plan replayer's scrubber so the move panel can drive the same state with its own presentation. Stacks on #921, and its base branch is fix/replayer-snapshot-keys.

Stack

  1. Read a plan model's output frame from where RDK writes it (read plan model output frame #910)
  2. Decode STL collision meshes and rotate unoriented link geometry (geometry decode fixes #912)
  3. Drive mimic joints from the joint they mimic (mimic joint fixes #913)
  4. Move the shared plan kinematics into $lib/motion (make motion utils reusable #917)
  5. Drive plan joints by RDK's schema order (match rdk joint numbering #918)
  6. Infer an untyped geometry's shape from the dimensions it sets (infer collisions #919)
  7. Read mesh data delivered as a number array (match rdk mesh data decoding #920)
  8. Keep a plan's snapshots with the plan when another is removed (replayer plan snapshot cleanup #921)
  9. This PR: Share trajectory playback between the replayer and the move panel
  10. Reconstruct RDK's flattened frame system from a robot's config (reconstructed flattened frames from rdk #923)
  11. Add client-side forward kinematics for placing a plan's frames (forward kinematics for player #924)
  12. Add interpolateTrajectory, budgeting preview frames per joint unit (budget frame movement between waypoints #925)
  13. Group a preview ghost with the component it stands in for when checking collisions (handle preview collisions #926)
  14. Add a client for the motion service's plan and execute do-commands (add do command wiring for planning and execution #927)
  15. Draw a previewed plan as ghost geometry alongside the live machine (add preview ghosts #928)
  16. Add the request lifecycle for a previewed move (preview lifecycle #929)
  17. Add move preview to the MoveFrame plugin (Motion plan preview #908)
  18. Fill in the frames between a previewed plan's waypoints (interpolation #930)

Frontend

  • createTrajectoryPlayer (new, $lib/motion/trajectoryPlayer.svelte.ts) owns currentStep, isPlaying, play / pause / toggle / seek / stepBy / reset, and the playback interval. totalSteps and intervalMs are getters rather than values, so a caller can re-pace a run in flight, and onStep reports every index the player moves to and can return false to refuse one.
  • TrajectoryScrubber.svelte (new, $lib/components/motion/) renders a player as a range track, five transport buttons and a step counter. It also draws waypoint tick marks over the track and a · waypoint N / M counter, both fed by an optional markers prop and both suppressed when every frame is a marker. Nothing passes markers until interpolation #930 hands it PreviewFrames.waypoints.
  • useMotionPlanReplayer.svelte.ts builds a player and publishes it as MotionPlanReplayerContext.player. applyStep now returns a boolean and refuses a step with no snapshot behind it, and setStep delegates to player.seek, which clamps and also pauses.
  • MotionPlanReplayerUI.svelte renders TrajectoryScrubber with label="motion plan". The old MotionPlanReplayerScrubber.svelte, which owned both the playback state machine and the transport markup, is deleted.
  • plugins/index.ts re-exports the TrajectoryPlayer type, because MotionPlanReplayerContext.player is public and its type has to be nameable.

Why?

Why does onStep run before currentStep commits?

The callback is what actually draws the frame, and it can fail: the replayer returns false when the snapshot array and the step count have come apart. Committing the index first means the counter reads a frame that was never drawn, and every relative step after that counts from a position the scene is not in.

Why does a refused step clear the interval instead of just pausing?

pause() sets isPlaying to false, which takes the timer down when the effect re-runs rather than immediately. In a browser that gap is usually invisible, because Svelte flushes effects in a microtask and the microtask checkpoint drains before the next timer callback fires. park() deliberately does not lean on that. Nothing about setInterval promises a checkpoint between callbacks, and under fake timers there is none, so clearing the interval directly is what makes the refusal take effect on the tick that produced it instead of some number of retries later against a scene that by definition cannot change.

Why must createTrajectoryPlayer be called during initialization?

The timer lives in an $effect, so calling the factory from a click handler, from module scope, or after an await throws effect_orphan rather than returning a player that quietly never advances. A consumer that wants a player per preview therefore builds one up front and re-points it at new steps, which is why totalSteps is a getter.

Why does the player rewind itself when its step count drops to zero?

Unloading the steps out from under a running player would otherwise leave it walking an empty range. The replayer cannot reach that today, since all four of its paths to zero steps call reset() first, so this is a safety net rather than a load-bearing path. It stays because nothing obliges a future consumer to call reset() before swapping its step source out, and a playing timer over an empty range is a worse failure than a redundant guard.

Why does the scrubber pause the player on unmount?

The player outlives the component that renders it: the replayer builds one at its plugin root, above both the monitor-mode gate and the panel's own {#if isOpen}, and the move panel will build one per preview. FloatingPanel renders its children behind that {#if}, so closing the panel really does unmount the scrubber while the player keeps running. Nothing else stops the timer, so playback would keep reconciling the world every frame with no way on screen to stop it.

Why aria-disabled rather than disabled on the transport?

The ends of a trajectory flip two of the five buttons unavailable while you are using them, and disabled drops the focused control out of the tab order underneath a keyboard user mid-scrub. aria-disabled keeps it focusable, which is what svelte.md asks for and what the upload button in this same panel already does. It leaves the click live, so each handler re-checks: stepBy would clamp to a no-op on its own, but seek would re-render the step it is already on.

Why do the tick marks correct for the thumb?

A range input's thumb center travels from half a thumb in to half a thumb short of the end, not the full width of the track. A mark placed at a bare percentage is therefore off by up to half a thumb, 6px left at the first frame and 6px right at the last, and correct only in the middle. The ends are exactly where someone looks to check whether the thumb is sitting on a mark. The correction lives in the .tick rule as calc(var(--tick-fraction) * (100% - var(--thumb-size)) + var(--thumb-size) / 2), with the thumb diameter passed in from the one place it is defined.

Why does the scrubber not validate markers?

Because the only thing that could produce a bad one is a caller, and a defensive sort or clamp here would be code no test could ever reach. markers has to be ascending, deduplicated and within range: the counter walks until it passes currentStep, so an unsorted array stops early and reads a plausible wrong number, a duplicate inflates both halves of the count, and an out-of-range entry places a tick off the end of the track. PreviewFrames.waypoints is documented to start at 0, end at the last index, and give every planned waypoint its own frame, so it satisfies all three. The contract is stated on the prop as well as on the producer.

Why is this a minor release rather than a patch?

patch is reserved for internal refactors. This adds readonly player to MotionPlanReplayerContext, which is exported from ./plugins, and it changes what setStep does for anyone already calling it: it now pauses. Neither is internal.

Testing

npx vitest run passes 820 tests across 74 files, up 52 from fix/replayer-snapshot-keys's 768 across 72. svelte-check reports 0 errors and 0 warnings. npx prettier --check is clean.

Two new spec files carry 48 of the 52. trajectoryPlayer.spec.ts drives a real player through an $effect.root harness under fake timers, covering clamping, truncation and the non-finite guard on seek, relative stepping, the interval advancing and parking itself at the end, re-pacing a run in flight, rewinding on toggle at the end, and the refusal channel: a refused step leaves the index on the frame still on screen, the next relative step counts from there, and playback parks rather than retrying the same index every tick. TrajectoryScrubber.svelte.spec.ts renders the component against a stub player for the readouts and the transport wiring, and against ScrubberHarness when the player has to actually respond, which is how the unmount test proves the timer stops rather than that pause was called.

The remaining 4 are in useMotionPlanReplayer.svelte.spec.ts, covering the seam: setStep pauses, reselecting or removing the active plan rewinds the index, and a plan whose snapshot array is short or gappy holds the index on the frame that is still drawn.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2a7b8e3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@viamrobotics/motion-tools Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://viamrobotics.github.io/visualization/pr-preview/pr-922/

Built to branch gh-pages at 2026-08-13 18:47 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@DTCurrie

Copy link
Copy Markdown
Member Author

Claude (@claude) review

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished Devin T. Currie (@DTCurrie)'s task in 4m 22s —— View job


Review

  • Read CLAUDE.md and gather context
  • Read all modified/added files
  • Analyze correctness, API design, patterns, and tests
  • Post inline comments for line-specific feedback
  • Post review summary

Summary

The architecture is solid: createTrajectoryPlayer is a well-contained factory with a clean interface, TrajectoryScrubber separates presentation from state correctly, and the refusal channel (onStep returning false to park playback) is an elegant way to handle the snapshot/step-count skew without coupling the player to the replayer's internals. Tests are thorough and cover the important edges (NaN seek, refused steps, timer restarting after re-pace, unmount stopping the timer rather than just calling pause).

Three things to address:

Bug (minor)
toggle() calls play() even when moveTo(0) is refused — when parked at the end, if step 0 can't be drawn, isPlaying flips to true then back to false on the next timer tick. One guard (if (atEnd && !moveTo(0)) return) closes it.

Convention (required by svelte.md)
The $effect that writes isPlaying/currentStep when total drops to 0 needs an in-code comment explaining why it's an $effect rather than a $derived. The rationale exists in the PR description but won't survive the PR. Without it, the first reader who sees state being written from an $effect will rightly reach for the refactor, not knowing there's a reason to resist.

Observation (nit)
currentStep and totalSteps on MotionPlanReplayerContext are now reachable via player.currentStep / player.totalSteps. Worth confirming whether they're kept for external compatibility or can be dropped.


What looks good:

  • The timer's park() clearing the interval directly rather than relying on the effect re-run is the right call; the PR's explanation of why (fake timers don't guarantee a microtask between callbacks) is exactly right, and the test proves it.
  • The oninput write-back (input.value = String(player.currentStep)) correctly handles both accepted and refused seeks without a second Svelte render pass.
  • Thumb-center correction in .tick is pixel-accurate and has browser-mode test coverage.
  • aria-disabled rather than disabled on the transport buttons is the right choice (matches svelte.md) and tested.
  • The harness split (trajectoryPlayerHarness.svelte.ts vs ScrubberHarness.svelte) is clean: the player harness owns refusal-channel testing, the scrubber harness owns unmount/timer testing, and the stub player covers everything that just needs wiring verified.
  • $effect.root in the player harness is the right way to house $effects in a test that isn't a component render.

Comment on lines +134 to +141
toggle: () => {
if (isPlaying) {
pause()
return
}
if (atEnd) moveTo(0)
play()
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case: play() fires even when the rewind is refused

If atEnd is true and moveTo(0) returns false (e.g. snapshot for step 0 is missing), play() is still called unconditionally. isPlaying becomes true, but on the very next timer tick currentStep >= lastStep fires park(), so isPlaying flips back to false 100ms later — a brief flicker.

The fix is one guard:

Suggested change
toggle: () => {
if (isPlaying) {
pause()
return
}
if (atEnd) moveTo(0)
play()
},
toggle: () => {
if (isPlaying) {
pause()
return
}
if (atEnd && !moveTo(0)) return
play()
},

Step 0 is usually renderable (it's what loadPlan applies on select), so this won't trigger in practice, but the timer-and-park path is real and subtle enough to eliminate.

Comment on lines +110 to +114
$effect(() => {
if (total > 0) return
isPlaying = false
currentStep = 0
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$effect writing state — add an in-code comment explaining the exception

svelte.md says "Never use $effect to derive state — use $derived". This block writes to both isPlaying and currentStep, which looks like exactly that. The PR description explains why a $derived doesn't work here (the two state variables have independent writers, and the safety net has to silence the timer immediately), but that reasoning lives only in the PR and not next to the code.

Please add a brief inline comment so a future reader doesn't refactor this into a $derived and wonder why the tests start flaking. Something like:

Suggested change
$effect(() => {
if (total > 0) return
isPlaying = false
currentStep = 0
})
// $effect, not $derived: both `isPlaying` and `currentStep` have independent writers, so they
// can't be derived from `total` alone. This is a safety net — a consumer that swaps the step
// source without calling `reset()` would otherwise leave a timer walking an empty range.
$effect(() => {
if (total > 0) return
isPlaying = false
currentStep = 0
})

Comment on lines 39 to 40
readonly currentStep: number
readonly totalSteps: number

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentStep and totalSteps are now redundant on the context

player.currentStep and player.totalSteps expose exactly the same values now that player is public. Keeping both means two sources of truth and two things to keep in sync. Removing them would be a breaking change for callers already destructuring ctx.currentStep, which may be why they're still here — but if no external caller uses them, this is a good opportunity to drop them and point readers to player.* instead.

Worth confirming whether any consumer outside this PR uses ctx.currentStep / ctx.totalSteps directly, and either removing them or adding a brief comment that they're kept for compatibility.

@DTCurrie
Devin T. Currie (DTCurrie) force-pushed the refactor/shared-trajectory-player branch from f525214 to 41badc9 Compare August 13, 2026 18:25
@DTCurrie
Devin T. Currie (DTCurrie) force-pushed the refactor/shared-trajectory-player branch from 41badc9 to 2a7b8e3 Compare August 13, 2026 18:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant