Skip to content

handle preview collisions - #926

Open
Devin T. Currie (DTCurrie) wants to merge 4 commits into
feat/preview-frame-budgetfrom
fix/preview-collisions
Open

handle preview collisions#926
Devin T. Currie (DTCurrie) wants to merge 4 commits into
feat/preview-frame-budgetfrom
fix/preview-collisions

Conversation

@DTCurrie

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

Copy link
Copy Markdown
Member

Teaches the collision layer about preview ghosts, so a plan drawn through an obstacle reads as a warning about the move rather than as something happening now. Stacks on #925.

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. This PR: Report a previewed collision as a warning about the move
  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

  • traits.ts is new. PreviewOf names the component a preview ghost stands in for, previewedComponent reads it back, and previewComponentName derives that name from a frame name.
  • collisionMembers.ts resolves a preview ghost's group bit from PreviewOf instead of from the hierarchy walk, and isGhost recognizes both kinds of ghost.
  • collisionReport.ts labels a preview ghost with the component it previews, so the pair reaches the "Move would collide" banner under a real name rather than the "Currently touching" list as unnamed.

Why?

Why land this before anything sets PreviewOf?

The spawner is two PRs up, and the specs here construct the entities directly. The grouping rule is the subtle part of the feature, and it can be stated and tested without a plan, a scrubber, or a robot.

Why a trait holding a name, rather than a relation like GhostOf?

Because a preview ghost is a moment of a component rather than a copy of one, so there is no source entity to point at. It also deliberately carries no Name and no ChildOf. Staying out of the hierarchy is what stops it capturing a live entity's children through resolveOrphans, which resolves an orphan by name collision, but it also leaves the ghost with no way at all to answer "which arm do I belong to". That answer is exactly what the collision check needs, so the ghost carries the name itself.

Why is the part everything before the last colon, not the first?

Because a colon is RDK's remote delimiter as well as its link delimiter, and it is only ever a delimiter: : is a reserved character in a resource name (resource/resource.go), so it can never be part of one. A remote arm is myremote:arm with links myremote:arm:wrist_1_link, and remotes nest. Splitting on the first colon answers myremote, which is neither an armBits key (those come from useResourceNames, giving myremote:arm) nor any live frame's name, so every ghost of a remote part would fall through to the environment, whose filter includes every arm bit. Since a ghost sits exactly on its live twin at step 0, that is a guaranteed collision report against the arm the ghost is drawn on top of.

Stripping _origin before the split is what makes the last-colon rule exact rather than a guess. _origin is appended to a whole part name (frame_system.go), so a name ending in it settles the question outright, and every other frame reaching the function is <part>:<link>. A bare part frame is never ghosted, because it carries no geometry, which matters: myremote:arm on its own is genuinely undecidable, and the spawner never asks.

Why does previewComponentName mis-parse a link named <link>_origin?

Because stripping _origin first assumes the suffix is only ever appended by frameSystemToPlanFrames, and nothing enforces that. A URDF may legally name a link wrist_origin, so arm:wrist_origin returns arm:wrist. Colon-first is not a fix either, since it breaks myremote:arm_origin. The exact fix is to stop re-deriving the part name here and carry it from frameSystemToPlanFrames, which builds both names and knows which is which, and that spans more than this PR. None of the four reference captures contains such a frame. traits.spec.ts pins the answer so a future change to this function has to change it on purpose.

Why is the fallback a ladder rather than one lookup?

The three rungs answer three different questions and each is load-bearing. armBits.get(previewed) answers for an arm, whose component name is an arm resource name. The parent walk answers for a gripper or a camera: those are their own components, mounted on an arm, so their names are not in armBits at all and only the hierarchy knows which arm they hang off. Indexing armBits alone drops exactly those into the environment, coincident with the twin they mirror. Falling through to the environment is the honest answer when the name resolves to nothing live, or to something with no arm above it, because neither says which arm owns the ghost.

Why does a preview ghost still test against the environment?

Because that is the whole point. A ghost is filtered against its own component so it never reports touching itself at another moment, but a plan drawn through a table is precisely what a preview is for.

Testing

pnpm exec vitest --run passes 1006 tests across 80 files, up from 975 across 78 on feat/preview-frame-budget. pnpm exec svelte-check reports no errors.

collisionMembers.spec.ts covers the ladder rung by rung: an arm answering from its own name with nothing live to walk, a mounted gripper resolving through its arm, a subject that is not in the scene, and a subject with no arm above it. It also covers a remote-qualified part end to end, both for the group bit and for the Rapier interaction mask.

collisionReport.spec.ts is new and covers the labeling and the staged flag, asserting the real name rather than merely that it is not unnamed.

traits.spec.ts is new and covers previewComponentName across local, remote, _origin and link forms.

Each behavior was verified by reverting the line that implements it and confirming the matching test fails, including both collapses of the ladder.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 988c7ab

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 7, 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-926/

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

@DTCurrie
Devin T. Currie (DTCurrie) force-pushed the fix/preview-collisions branch 2 times, most recently from 23bafb6 to c4f87cf Compare August 11, 2026 19:07
@DTCurrie
Devin T. Currie (DTCurrie) force-pushed the fix/preview-collisions branch 2 times, most recently from 3580dae to cdd388e Compare August 12, 2026 14:42
@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 3m 1s —— View job


Review

  • Read CLAUDE.md and gather context
  • Read changed files
  • Run git diff
  • Analyze code and write review
  • Post inline comments for line-level feedback

The implementation is solid. The three-rung fallback in collectMembers, the _origin-first / last-colon parse, and the choice to carry the name on the ghost rather than use a relation are all well-reasoned; the PR description explains each tradeoff precisely. Tests are well-organized and use the "revert the line, confirm the failure" discipline. Two minor issues:

Nits

Redundant test (collisionReport.spec.ts:77) — 'reaches the banner rather than the currently-touching list' asserts only staged: true, which is already fully asserted by the toEqual on the test immediately above it. Having two tests for the same fact suggests they describe different things and misleads on failure.

isGhost describe block is incomplete (collisionMembers.spec.ts:164) — The existing isGhost block covers only the GhostOf path. Every staged: true assertion in collisionReport.spec.ts depends on the new PreviewOf branch being true, but a reader looking at the unit block for isGhost directly sees only half the function.

Observations (no action needed)

  • previewComponentName('_origin') returns '', which previewedComponent normalizes to undefinedENVIRONMENT_BIT. Harmless, unreachable, but quietly correct.
  • The dedup key in toReports (${first}-${second}) can collide when component names contain - (e.g. 'a-b'+'c''a'+'b-c'). Pre-existing; not introduced here.
  • Playwright browser binaries are absent in this CI environment, so pnpm test could not be verified locally — CI results stand.

])
})

it('reaches the banner rather than the currently-touching list', () => {

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.

This test is entirely covered by the one above it — toEqual([{ a: 'left-arm', b: 'obstacle-table', staged: true }]) already asserts staged: true. The body is a strict subset of the assertion on line 72.

Either drop it, or replace both with a single test that asserts the full shape and anchors the label. Having two tests for the same fact suggests they describe different things, which misleads a reader who hits a failure.

expect(bitOf(ghost, collectMembers(world, armBits))).toBe(ENVIRONMENT_BIT)
})

describe('preview ghosts', () => {

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 outer isGhost describe block (lines 94–102 of the file) covers only the GhostOf path. Now that isGhost also returns true for PreviewOf, a reader landing on that block sees an incomplete picture. It would be worth adding a direct case there:

it('recognises a preview ghost', () => {
    const ghost = world.spawn(PreviewOf('arm'))
    expect(isGhost(ghost)).toBe(true)
})

The behavior is covered indirectly through collisionReport.spec.ts's staged: true assertions, but the unit-level block for isGhost shouldn't have a silent gap.

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