Skip to content

Motion plan preview - #908

Open
Devin T. Currie (DTCurrie) wants to merge 3 commits into
feat/preview-lifecyclefrom
motion-plan-preview
Open

Motion plan preview#908
Devin T. Currie (DTCurrie) wants to merge 3 commits into
feat/preview-lifecyclefrom
motion-plan-preview

Conversation

@DTCurrie

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

Copy link
Copy Markdown
Member

Move mode can now show you a move before it runs: stage a goal on the gizmo, ask the motion service to plan it, and scrub the resulting trajectory as ghost geometry to see the path the machine would actually take. When the path looks right, Execute preview runs that exact trajectory instead of planning a fresh one. Stacks on #929, which supplies the preview lifecycle this panel mounts.

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. Keep a plan's snapshots with the plan when another is removed (replayer plan snapshot cleanup #921)
  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. This PR: Add move preview to the MoveFrame plugin
  18. Fill in the frames between planned waypoints (interpolation #930)

Frontend

  • MovePreview.svelte is the panel's preview section: the plan button, the error and already-at-goal messages, the approximation banner, and the trajectory scrubber.
  • MoveControls.svelte mounts it, wires usePreviewMove up with an invalidateOn key naming every input the plan was computed from, and gains an Execute preview button beside a relabeled Re-plan & execute.
  • moveExecutionOwner.svelte.ts names the single frame whose move is currently running, across every open panel. It is the twin of moveGizmoOwner.

Why?

Why does the panel need its own execution lock?

Because execute is not Move. builtIn.Move opens with operation.CancelOtherWithLabel(ctx, builtinOpLabel) in services/motion/builtin/builtin.go, so two client.move calls arbitrate themselves inside RDK. builtIn.DoCommand does neither: no operation label, and only a read lock. Move mode renders a panel per selected frame and the execute buttons are not gated on owning the gizmo, so selecting an arm and a gripper mounted on it and executing both would batch GoToInputs for the same arm from two different trajectories. The server structurally cannot arbitrate this, so the UI has to. It holds a frame name rather than a boolean so a panel can tell "I am the one moving" from "someone else is": the first shows progress, the second disables.

Why does re-planning trigger on more than the goal moving?

Because the plan depends on more than the goal. Editing the world state JSON adds an obstacle; editing constraints changes what counts as a valid path; switching motion service changes who answers; and a config revision replaces the kinematics the ghosts are drawn through. All of them make the displayed trajectory wrong in a way the user cannot see, and editing the world state to describe an obstacle the preview just revealed is the whole reason the field exists, so invalidateOn names all five.

Why does Execute preview not replan?

That is the point: the preview is what is being approved. executeCommand arms RDK's own start-state guard, so a component that has drifted away from the configuration the plan begins at refuses rather than flying a path nothing validated. The world it was planned against is still a snapshot, and a dynamic obstacle that has moved since is invisible to both sides, which is why Re-plan & execute stays available alongside it.

Why clear the preview when a move fails?

A failed execute is not a move that never happened. RDK batches the waypoints to the component and can stop anywhere along them, so whatever configuration the machine is in afterwards is not the one the plan starts from. Leaving the drawing up, with Execute preview armed over it, offers to re-run a path from a state that no longer exists. The same applies to Re-plan & execute, which clears up front so that committing to a move also cancels any plan still in flight.

Why gate the buttons on the client rather than the service name?

They are not the same thing. useResourceNames serves names from cache with staleTime: Infinity, while createResourceClient yields undefined for as long as the connection is not CONNECTED. On a dropped socket the service is set and the client is not, so gating on the name alone leaves the button lit while clicking it does nothing at all: no spinner, no error, no state change.

Why does the ready state call the preview an approximation?

Because a plan is a validated path and nothing more, and every decision about how to fly it is made later, by the component. Plans carry no timing, so the scrubber plays at a fixed rate that is not the speed the machine will move at. And what the planner guarantees is that the path is collision-free, not that the arm traces it exactly: builtin.execute hands the whole waypoint list to the component in one batch precisely so it can decide how to move between the waypoints. Both are true of every plan, always, and nothing has gone wrong when they are, so the banner is styled as information rather than as a warning.

Why do an unsupported RDK and an unsupported api report differently?

Because they fail independently, and a robot can satisfy one and not the other. The preview needs RDK v0.101.1 or newer for the plan and execute DoCommands on the builtin motion service; older versions return no plan key, which surfaces as "this motion service does not support previewing". It separately needs api v0.1.485 or newer for FrameSystemConfig.kinematics to carry the model, without which there are no kinematics to run and the preview reports an empty frame system. Collapsing the two into one message would send people looking at the wrong half. executeCheckStart (#927) is honored from RDK v0.101.1 onward and ignored rather than rejected before it, so Execute preview still runs on an older server, just without the start-state guard.

Testing

pnpm exec vitest --run passes 1114 tests across 84 files, up 18 tests and one file from feat/preview-lifecycle.

MovePreview.svelte.spec.ts is new. It drives the component off plain PreviewMove objects rather than mocking anything, and covers the idle state with neither live region on screen, each error and already-at-goal message through role="alert" and role="status", and the ready state's approximation banner and scrubber.

MoveControls.svelte.spec.ts covers the Execute preview path and what it sends, that the start-state check is armed, that a second panel cannot execute while the first move is still running, that a failed execute drops the drawn plan, that a missing client disables the buttons rather than leaving them armed and inert, and that the goal, the world state, the constraints and the frame system each re-key the preview.

Each behavior was verified by reverting the line that implements it and confirming the matching test fails.

pnpm exec svelte-check reports 0 errors and 0 warnings.

@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0395163

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 3, 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-908/

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

@DTCurrie
Devin T. Currie (DTCurrie) changed the base branch from main to fix/mimic-joints August 5, 2026 19:40
@DTCurrie
Devin T. Currie (DTCurrie) changed the base branch from fix/mimic-joints to feat/plan-do-command August 6, 2026 18:38
@DTCurrie
Devin T. Currie (DTCurrie) changed the base branch from feat/plan-do-command to feat/preview-lifecycle August 6, 2026 20:13
This was referenced Aug 6, 2026
@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 53s —— View job


Review

  • Read CLAUDE.md and rules
  • Read all changed files
  • Run git diff against base branch
  • Analyze code for correctness, security, patterns, and tests
  • Post inline comments for line-specific issues
  • Post review summary

Summary

No bugs found. The design is clean and the core execute paths are correct. Three test gaps worth closing before merge.


Correctness

moveExecutionOwner.svelte.ts — The module-level $state singleton matches moveGizmoOwner exactly. claim/release are synchronous in a single-threaded runtime, so there is no TOCTOU. Holding the frame name instead of a boolean is the right call: the "I am the one moving" / "someone else is" distinction drives two different UI states, not one.

executePreviewedMove — Guard ordering is correct: preview.status !== 'ready' is checked before claim(), so a stale click on a button that has just been disabled never acquires the lock. preview.trajectory is captured by executeCommand synchronously before the await, so a clear() fired by the $effect while the command is in-flight doesn't corrupt what was sent to RDK. The finally block releases unconditionally.

executeMove migration — The switch from executing = true/false to moveExecutionOwner.claim/release is clean. preview.clear() before client.move() correctly drops an in-flight plan so its result can't land on top of a configuration the machine has already left.

MovePreview.svelterole="alert" for errors and role="status" for already-at-goal is the right semantic split. The approximation banner correctly uses border-info-medium bg-info-light text-info-dark tokens. The disabled || planning guard on the plan button correctly prevents double-planning.

invalidateOn — All five inputs (targetWorldMatrix, worldStateJson, constraintsJson, service, frames.parts) are included. The $effect in usePreviewMove tracks them reactively and calls clear() on any change.


Test gaps (see inline comments)

  1. Service change is not covered (line 290 in MoveControls.svelte.spec.ts): the it.each for invalidation covers 4 of 5 inputs. service is in invalidateOn and called out in the PR description, but has no test case.

  2. executeMove with a ready preview (line 411 in MoveControls.svelte.spec.ts): no test verifies that clicking "Re-plan & execute" when preview.status === 'ready' calls preview.clear() before the motion call. This protects the documented invariant that committing to a move cancels any plan still in flight.

  3. planning state in MovePreview.svelte.spec.ts (line 73): the suite goes idle → ready/error. The in-progress state (button disabled + indeterminate progress) is untested. Minor — the state is transient — but the other states are all explicit.


Positives worth calling out

  • satisfies TrajectoryPlayer and satisfies PreviewMove on the mocks mean a field added or removed to either interface will fail the spec immediately — this is the right pattern per testing-frontend.md.
  • The beforeEach cleanup of moveExecutionOwner (release(held)) is essential since it is module-level state; missing it would cause tests to bleed into each other.
  • MockMoveJsonField.svelte correctly uses a textarea to simulate the CodeMirror field's onChange contract without pulling in the editor dependency.
  • The concurrent-panels test (deferred Promise) correctly exercises the lock without timing tricks.

await change(user)

expect(invalidationKey()).not.toEqual(before)
})

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.

The it.each covers 4 of the 5 inputs listed in invalidateOn. The service name (service) is included in the array but has no corresponding test case here. The PR description explicitly calls this out as a reason for the design ("switching motion service changes who answers"), so it's worth covering.

A fifth entry would look like:

{
  input: 'the motion service changes',
  change: async (user: UserEvent) => {
    // Re-mount with a second service in the list so the combobox has an alternative to select.
    // Would require withBuiltinService() to expose two services: [service('builtin'), service('planner')]
    // then simulate selecting 'planner'.
  },
},

The tricky part is that withBuiltinService() only returns one service, so selecting a different one requires a two-service setup. Still worth adding — this is the only input without test coverage.


await waitFor(() => expect(preview.clear).toHaveBeenCalled())
})
})

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.

Missing test: the PR description says "Re-plan & execute clears up front so that committing to a move also cancels any plan still in flight." But there is no case that:

  1. Sets preview.status = 'ready'
  2. Clicks the "Re-plan & execute" button (relabeled from "Execute move")
  3. Asserts preview.clear was called before client.move

The existing executeMove tests in the base branch never have a ready preview, so this interaction is uncovered. Adding a describe('with executeMove while a preview is ready') block would close the gap and protect the preview.clear() call on line 288 of MoveControls.svelte.

expect(screen.getByRole('slider', { name: 'arm preview step' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /re-plan preview/i })).toBeInTheDocument()
})
})

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.

The planning status is untested. It renders the plan button with progress="indeterminate" (the spinner) while disabling it — a transient state but a user-visible one. A simple case would verify both:

it('shows planning progress while a plan is in flight', () => {
  render(MovePreview, {
    props: { preview: preview({ status: 'planning' }), frameName: 'arm' },
  })

  const button = screen.getByRole('button', { name: /preview move/i })
  expect(button).toHaveAttribute('aria-disabled', 'true')
  // The progress indicator: prime's Button renders aria-busy while progress is set.
  // Adjust to whatever attribute prime-core exposes.
})

Not blocking, but the current suite goes from idle straight to ready and error, leaving the in-between state undocumented.

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