handle preview collisions - #926
Conversation
🦋 Changeset detectedLatest commit: 988c7ab The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
934c81b to
c63fef3
Compare
c63fef3 to
c6aaeb8
Compare
|
c6aaeb8 to
12c1d14
Compare
12c1d14 to
163cbaa
Compare
163cbaa to
5812ef2
Compare
5812ef2 to
e54b042
Compare
e54b042 to
cc9f41b
Compare
23bafb6 to
c4f87cf
Compare
3580dae to
cdd388e
Compare
|
Claude (@claude) review |
|
Claude finished Devin T. Currie (@DTCurrie)'s task in 3m 1s —— View job Review
The implementation is solid. The three-rung fallback in NitsRedundant test (
Observations (no action needed)
|
| ]) | ||
| }) | ||
|
|
||
| it('reaches the banner rather than the currently-touching list', () => { |
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
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.
cdd388e to
3d9e213
Compare
3d9e213 to
988c7ab
Compare
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
$lib/motion(make motion utils reusable #917)MoveFrameplugin (Motion plan preview #908)Frontend
traits.tsis new.PreviewOfnames the component a preview ghost stands in for,previewedComponentreads it back, andpreviewComponentNamederives that name from a frame name.collisionMembers.tsresolves a preview ghost's group bit fromPreviewOfinstead of from the hierarchy walk, andisGhostrecognizes both kinds of ghost.collisionReport.tslabels 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 asunnamed.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
Nameand noChildOf. Staying out of the hierarchy is what stops it capturing a live entity's children throughresolveOrphans, 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 ismyremote:armwith linksmyremote:arm:wrist_1_link, and remotes nest. Splitting on the first colon answersmyremote, which is neither anarmBitskey (those come fromuseResourceNames, givingmyremote: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
_originbefore the split is what makes the last-colon rule exact rather than a guess._originis 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:armon its own is genuinely undecidable, and the spawner never asks.Why does
previewComponentNamemis-parse a link named<link>_origin?Because stripping
_originfirst assumes the suffix is only ever appended byframeSystemToPlanFrames, and nothing enforces that. A URDF may legally name a linkwrist_origin, soarm:wrist_originreturnsarm:wrist. Colon-first is not a fix either, since it breaksmyremote:arm_origin. The exact fix is to stop re-deriving the part name here and carry it fromframeSystemToPlanFrames, 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.tspins 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 inarmBitsat all and only the hierarchy knows which arm they hang off. IndexingarmBitsalone 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 --runpasses 1006 tests across 80 files, up from 975 across 78 onfeat/preview-frame-budget.pnpm exec svelte-checkreports no errors.collisionMembers.spec.tscovers 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.tsis new and covers the labeling and thestagedflag, asserting the real name rather than merely that it is notunnamed.traits.spec.tsis new and coverspreviewComponentNameacross local, remote,_originand 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.