Skip to content

replayer plan snapshot cleanup - #921

Open
Devin T. Currie (DTCurrie) wants to merge 4 commits into
fix/mesh-data-shapesfrom
fix/replayer-snapshot-keys
Open

replayer plan snapshot cleanup#921
Devin T. Currie (DTCurrie) wants to merge 4 commits into
fix/mesh-data-shapesfrom
fix/replayer-snapshot-keys

Conversation

@DTCurrie

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

Copy link
Copy Markdown
Member

Fixes the plan replayer handing the active plan another plan's snapshots after a removal, so a scrub draws the plan you actually selected and the scrubber can reach the end of it. Stacks on #920.

snapshotStore was keyed by a plan's index in the plans array, which removePlan reindexes with a filter. Removing any plan ahead of the active one shifted every later plan down a slot, and each then read whichever snapshots had inherited its old position. activePlanIndex was adjusted to follow the shift; the store was not. Nothing threw, because setStep clamps against the array it has just fetched, so the read was always in range. What you got instead was the wrong robot on screen and a scrubber that would not finish: currentStep pinned at the short array's last index while lastStepIdx still came from the plan's own stepCount, so atEnd was never true, the play loop re-reconciled a single frame every 100 ms forever, and every forward control stayed enabled.

Stack

  1. Read a plan model's output frame from where RDK writes it (read plan model output frame #910)
  2. Geometry decode fixes (geometry decode fixes #912)
  3. Mimic joint fixes (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 from its dimensions (infer collisions #919)
  7. Read mesh data in both the shapes RDK sends it (match rdk mesh data decoding #920)
  8. This PR: Keep a plan's snapshots with the plan when another is removed
  9. Share trajectory playback between the replayer and the move panel (make trajectory playback reusable #922)
  10. Draw a part's configured geometry even when it has a kinematic model (reconstructed flattened frames from rdk #923)
  11. Place a plan's frames by running its kinematics on the client (forward kinematics for player #924)
  12. Budget preview frames per joint unit (budget frame movement between waypoints #925)
  13. Report a previewed collision as a warning about the move (handle preview collisions #926)
  14. Ask RDK to check the start state before executing a previewed plan (add do command wiring for planning and execution #927)
  15. Draw a previewed plan as ghost geometry (add preview ghosts #928)
  16. Run a previewed plan's lifecycle (preview lifecycle #929)
  17. Add move preview to the MoveFrame plugin (Motion plan preview #908)
  18. Fill in the frames between planned waypoints (interpolation #930)

Frontend

  • PlanState gains an id, assigned from a nextPlanId counter wherever a plan is created: the initialPlans map and addPlan. All four snapshotStore call sites key by it: addPlan's write, loadPlan's cached read, loadPlan's parse-path write, and setStep's read.
  • removePlan reads plans[index] first and returns if there is nothing there. The id lookup needs the entry, and returning early also stops an index that names no plan from reaching the activePlanIndex shift below it.
  • activePlanIndex still shifts on removal, because it genuinely is a position. It is now the only thing that is.
  • MotionPlanReplayerUI.svelte keys its {#each} on plan.id rather than plan.name.
  • The module-local setOrAddColor and setOrAddOpacity are replaced with $lib/ecs's shared setOrAddTrait.

Why?

Why does the panel's {#each} key change too?

Because this is the PR that gives it something stable to key on. Only the upload path in handlePlanFile rejects a duplicate name; neither addPlan, which is public API through ./plugins, nor the plans prop does. Svelte's each_key_duplicate is thrown in production builds as well as in dev, so two plans sharing a name would take the panel down on mount. Keying by id retires name uniqueness as a load-bearing invariant.

Why key addPlan's write by id when the plan is being appended at the end?

Because index = plans.length is only unique in a list nothing has been removed from. Add A, B and C, remove A, then add D: D takes index = 2, which is still C's key. Keyed by position, D's snapshots overwrite C's outright rather than merely being read in its place, so this one is corruption rather than a misread. loadPlan's parse-path write reaches the same store through the same arithmetic and has the same hole.

Why replace the two local trait helpers with setOrAddTrait?

The pair was there for a real reason, but the reason recorded above them was wrong. They claimed koota's set on an absent trait writes the store slot silently, leaving has false so nothing ever sees the value. It does not fail silently, it throws TypeError: Cannot read properties of undefined (reading 'store'). The guard is right either way, but a loud failure and a silent one are debugged very differently, and the shared helper in $lib/ecs already documents the real behavior plus add's mirror-image problem, where it returns early on a trait that is already present and drops the value it was passed. Both cases apply here: plan transforms carry no color metadata, so Color is always absent on spawn, while Opacity only happens to be present because drawTransform adds it unconditionally.

Why a component fixture rather than $effect.root?

provideMotionPlanReplayer reads the world and the relationship registry off Svelte context, and setContext only works during component initialization. The provider cannot be reached from a plain $effect.root, so ReplayerHarness.svelte is the smallest component that stands the three of them up together.

Why does the harness hand back the world as well as the context?

Because almost everything this module does lands in the world rather than on the context. Without it a spec can assert step counts and indices and nothing else, which is exactly why the display-config and entity-teardown behavior had no coverage. It is one extra return value and it is what makes the identity assertions possible.

Why do the fixture's snapshots name their plan?

This is the difference between a test that pins the bug and one that looks like it does. Cycling a single fixture's snapshots across every plan makes each plan's geometry byte-identical, so a test can only ever notice that it read an array of the wrong length. Reading the wrong plan's array of the same length, which is the failure this module actually had, draws a completely different robot and would pass. planSnapshots names the reference frame per plan, which makes the assertions about identity. The uuid is derived from the plan name too, stable across that plan's steps and distinct across plans, because reconcile keys on it: repeating it is what makes a scrub update the entity rather than respawn it, which the display-edit test depends on.

Why does the UI spec mock three things?

They are the three boundaries a spec in this repo is allowed to stub, external I/O aside, and nothing else here is mocked. FloatingPanel seeds its position from useThrelte().dom, which the global @threlte/core mock has no field for, so the real panel throws on mount outside a <Canvas>. DashboardPortal is imported from the $lib barrel, which re-exports App.svelte and drags the entire Threlte component tree in behind it; the component itself is only a Portal, already mocked globally to a passthrough, so a passthrough stand-in loses nothing. useToast needs a provideToast ancestor and nothing here checks toast content.

Testing

pnpm exec vitest --run passes 768 tests across 72 files, up 16 tests and 2 files from the base branch. pnpm exec svelte-check reports 0 errors and 0 warnings.

Reverting the fix to position keying fails 3 tests, including leaves the active plan reading its own snapshots, not its neighbour's on both its step count and expected [ 'plan-1-frame' ] to deeply equal [ 'plan-2-frame' ], which is the shipped bug exactly: plans of 2, 2 and 6 steps, remove the first, and the active 6-step plan reads its neighbour's array.

The assertion carrying that block is drawnFrames(world), not the step counts around it. totalSteps reads stepCount off PlanState, which stays correct even while the store read is wrong, so the counts read stronger than they are: on their own they catch a length mismatch and nothing else. Only the drawn frame names catch reading the wrong plan's array of the same length.

Three cases exist specifically because the naive version of them passes without the fix:

  • The negative and fractional index cases keep the active plan last on purpose. Only from the last position does a negative or fractional index satisfy the activePlanIndex > index comparison, so with the active plan held at index 1 both would pass with no guard at all.
  • keys a plan it parsed itself the same way, without landing on a live plan removes a plan before adding the parsed one, so position and id genuinely differ. Added into an untouched list the two coincide and keying by either one passes. does not overwrite a surviving plan when a new one is added after a removal forces the same divergence for addPlan.
  • colors a freshly spawned plan entity even though Color is always absent on spawn builds its own snapshot with a physicalObject, because applyStep colors only entities that got real geometry and the shared planSnapshots helper spawns bare ReferenceFrame markers. It is the only case that gates the Color half of the setOrAddTrait swap.

keeps display edits made while scrubbing covers the capture-and-restore block around reconcileSnapshotEntities, which had no coverage of any kind. Reconcile runs updateMetadata on every step, which resets Opacity and drops Invisible and ShowAxesHelper; those forty-odd lines exist to put a user's Details-panel edits back, and the comments there record that scrubbing wiping them is a regression this module has already had once. Deleting the whole block, or just the restore loop, passed the entire suite.

MotionPlanReplayerUI.svelte.spec.ts is a single test, and the render is the assertion rather than the setup for it: two plans sharing a name mount, and two Remove plan buttons come back. Under the old (plan.name) key Svelte throws each_key_duplicate before any query runs. The store spec's duplicate-name case pins the id generator but renders nothing, so this is the only thing constraining the {#each} key. Everything past the key still has no coverage, including the index threaded into selectPlan(i) and removePlan(i), which is this same class of bug one layer up.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b75d018

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 Patch

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-921/

Built to branch gh-pages at 2026-08-13 18:29 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 5m 17s —— View job


Review

The fix is correct and complete. The root cause — snapshot store keyed by mutable array position rather than plan identity — is diagnosed precisely, and the fix is applied consistently at all four write/read sites (addPlan write, loadPlan cached-read, loadPlan parse-write, setStep read). Nothing is missed.


Correctness

Core fix (useMotionPlanReplayer.svelte.ts): The id counter is closed over inside provideMotionPlanReplayer, so it starts fresh per replayer instance rather than leaking between tests. All four snapshotStore call sites are updated. The loadPlan parse path's spread { ...planState, status: 'ready', ... } preserves planState.id, so the id survives through to the store write at line 168 — this is the case the 'keys a plan it parsed itself the same way' test is specifically designed to catch and it would fail with position keying.

removePlan guard: plans[index] is undefined for out-of-range, negative, and fractional indices, so the single if (!removed) return gate handles all three. The activePlanIndex shift below it is correctly unreachable for those inputs without needing any explicit range or integer checks. See inline note at lines 206–208.

setOrAddTrait swap: The removal of setOrAddColor/setOrAddOpacity is clean. The old comments' claim that entity.set on an absent trait loses the value silently was incorrect (it throws TypeError); the test colors a freshly spawned plan entity even though Color is always absent on spawn now pins the real behavior. The shared setOrAddTrait doc in $lib/ecs/setOrAddTrait.ts describes it as "silently lost" — that's a pre-existing discrepancy in a file not touched here, but worth fixing separately.


Tests

The spec suite is thoughtfully constructed. A few specific observations:

  • drawnFrames over step counts: Asserting frame names catches "reading the wrong plan's array of the same length," which a step-count check alone cannot. This is the right tool for the shipped bug (see inline nit on the name).
  • Parameterised invalid-index cases (it.each): Placing the active plan last is the right arrangement — from the middle, neither negative nor fractional satisfies activePlanIndex > index, so both would pass without a guard.
  • 'does not overwrite a surviving plan when a new one is added after a removal': The setup (remove first, then add) forces position and id to diverge, which is necessary because in an untouched list they coincide and keying by either would pass. Same structural discipline in 'keys a plan it parsed itself the same way'.
  • keeps display edits made while scrubbing: First coverage of the preserved capture-restore block; the PR description is right that deleting it would have passed the full suite beforehand.
  • UUID format in fixture (see inline at line 44): plan-0-0000-4000-8000-000000000000 is not a valid 8-4-4-4-12 UUID. The tests pass because UuidTool is apparently lenient, but this could become fragile if the library tightens its parser.

Minor

  • PlanState not exported (see inline at line 24): It appears in MotionPlanReplayerContext['plans'] so it's already on the public surface by inference. Callers that need to type a stored plan entry have to use MotionPlanReplayerContext['plans'][number].
  • handlePlanFile still gates by name: The UI layer's ctx.plans.some((p) => p.name === name) check is the right place for UX-level name deduplication after the store no longer requires it. The separation is correct.
  • No coverage of selectPlan(i) / removePlan(i) index threading in the UI spec: Acknowledged in the PR description. The one UI test is the right minimum — it pins the {#each} key change and nothing more.

Approve once the UUID nit and the PlanState export are addressed. Everything else is a low-stakes observation.

}

// Only primitives here — proto objects (Snapshot[]) live outside $state to avoid Svelte 5 deep proxy
interface PlanState {

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.

PlanState is not exported but it's the element type of MotionPlanReplayerContext.plans, so it's already part of the public API surface by inference. Callers who need to declare a typed variable for a plan entry have to reach for MotionPlanReplayerContext['plans'][number]. Since id is newly present and the UI already uses it as a key, exporting the interface would make consumption cleaner.

Suggested change
interface PlanState {
export interface PlanState {

Comment on lines +60 to +65
const drawnFrames = (world: World): string[] =>
world
.query(traits.Name)
.map((entity: Entity) => entity.get(traits.Name))
.filter((name): name is string => typeof name === 'string' && name.endsWith('-frame'))
.toSorted()

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.

drawnFrames is the right assertion primitive here — it catches "reading the wrong plan's array of the same length" where a simple step-count check would silently pass. One nit: the helper name is singular (frame) but it returns multiple names; drawnFrameNames or queryFrameNames would be self-documenting without needing the JSDoc above it.

poseInObserverFrame: new PoseInFrame({ referenceFrame: 'world' }),
// Stable across a plan's steps, distinct across plans: reconcile keys on this, so
// repeating it is what makes a scrub update the entity rather than respawn it.
uuid: Uint8Array.from(UuidTool.toBytes(`${plan}-0000-4000-8000-000000000000`)),

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.

UuidTool.toBytes expects a standard xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx format (8-4-4-4-12). plan-0-0000-4000-8000-000000000000 is 6-4-4-4-12, which is invalid. This works today because the tests pass (presumably UuidTool is lenient or produces usable bytes from the hex digits it finds), but if the library ever tightens its parser this silently produces wrong bytes rather than a test failure. Padding to 8 chars — e.g. 00plan-0-0000-4000-8000-000000000000 — would produce a well-formed UUID and keep the per-plan distinctness.

Comment on lines 206 to +208
const removePlan = (index: number) => {
const removed = plans[index]
if (!removed) return

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.

Neat: plans[index] being undefined for negative, fractional, and out-of-range indices is the single guard that handles all three bad-input cases. The test confirms each one, and this is meaningfully simpler than explicit range/integer checks would be.

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