Skip to content

reconstructed flattened frames from rdk - #923

Open
Devin T. Currie (DTCurrie) wants to merge 5 commits into
refactor/shared-trajectory-playerfrom
fix/part-origin-geometry
Open

reconstructed flattened frames from rdk#923
Devin T. Currie (DTCurrie) wants to merge 5 commits into
refactor/shared-trajectory-playerfrom
fix/part-origin-geometry

Conversation

@DTCurrie

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

Copy link
Copy Markdown
Member

Reconstructs the flattened frame system RDK's motion service builds, from what a robot will actually hand a browser, so a part's configured geometry is drawn even when the part also carries a kinematic model. Stacks on #922. Nothing imports it yet; the move preview in #908 is the consumer.

The plan DoCommand answers with a trajectory and nothing else, joint values and no poses, so anything that wants to draw a plan has to supply the kinematics itself. The plan replayer gets them from RDK's debug dump, which no RPC exposes. What is exposed is robot.frameSystemConfig: one FrameSystemConfig per part carrying that part's offset from its parent plus, in kinematics, the very same model config the dump nests at frames['left-arm'].frame.model. The gap between the two is the flattening RDK does when it builds the frame system, and that is what this file reproduces.

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. This PR: Draw a part's configured geometry even when it has a kinematic model
  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

  • frameSystemToPlanFrames(parts) emits the frames RDK produces per part: p_origin for the mount offset and geometry, p for the model or an identity placeholder, and p:<link.id> / p:<joint.id> for the model's own nodes, each parented inside the model's namespace.
  • p_origin carries the part's configured geometry unconditionally, matching createFramesFromPart.
  • A model with no degrees of freedom whose part configured a geometry emits a bare static p and no p:<link> frames, matching the same function.
  • drawableModelOf validates a model whole before any of it is emitted, and rejects it entirely if RDK would have. That covers DH kinematics, a joint type JointConfig.ToFrame cannot build, and a node with no id.
  • A part whose generated p_origin collides with another part's own name is skipped with a warning rather than overwriting it.
  • Each part is read in isolation, so a config that cannot be parsed costs that part and no others.
  • frameDescriptors.ts gains DECODED_FRAME_TYPE and a DecodedFrame shape, plus the switch case that turns one into a static descriptor.
  • inferGeometryType now also runs for frames that came from a model config, where RDK has not yet resolved an empty "type" into a named one.

Why?

How does RDK flatten a frame system, and what does this file have to reproduce?

For a part p, RDK publishes four kinds of frame. p_origin holds p's offset from its parent plus the part's configured geometry, and is parented to whatever p is parented to. p holds the model, or an identity placeholder, and is parented to p_origin. p:<link.id> holds the link's LinkConfig and p:<joint.id> holds the joint's JointConfig, each parented to its own p:<parent> inside the model's namespace, falling back to p_origin for a node that names world or names nothing. A link naming world inside a model means the model's own mount, not the scene root. This is verified against __fixtures__/plan.json, a dual-arm rig whose dump contains exactly this shape.

The one asymmetry worth naming: a part without a model carries its geometry on p_origin and leaves p an empty frame. That is what RDK does, and it is why p is emitted at all, since descendants are parented to the bare name and the frame has to exist for them to hang off.

Why a frame type that RDK does not have?

A part's own pose and geometry arrive over this route as protobuf, not as Go's JSON marshal of a GeometryConfig. Re-encoding them into a static frame purely so the existing switch could decode them again would round-trip a proto Geometry through a lossy intermediate. Carrying them already decoded avoids that. Only the part-level frames need it: a model's links and joints come off the wire as the same LinkConfig / JointConfig JSON the plan dump carries, so they take the normal path.

Why does a jointless model drop its links?

Because RDK does. Its predicate is len(modelFrame.DoF()) == 0 && len(offsetGeom.Geometries()) > 0, which for an SVA model is the joint count: a model with no degrees of freedom and a geometry configured on the part is not published as a model at all. The model frame is replaced with a bare static one, and a static frame is not flattenable, so no p:<link> frames exist for it downstream. The user's shape was meant to replace the model's. Drawing the links anyway shows collision volumes RDK discarded, doubled up with the one that replaced them, so the stand-in carries no geometry of its own either.

The stand-in sits at identity, where RDK puts it at modelFrame.Transform([]Input{}). That is the model's whole extent at rest rather than a small internal offset: a two-link gripper 10 mm and 40 mm long puts RDK's frame 50 mm away from this one. Computing it would mean a second forward-kinematics implementation in this file. Anything parented to the bare part name is off by that much, and nothing else is. This is called out in the code at the branch.

Why reject a whole model over one bad node?

Because half-rejecting it was worse than useless. Skipping a single unsupported joint left everything below it still naming p:<jointId>, a key with no frame behind it. Those frames were not dropped along with the joint; they resolved to no parent at all and drew at the scene origin. A fuzz of 4,000 random configs produced 933 such dangling parents.

Rejecting the model is also what RDK does. JointConfig.ToFrame returns NewUnsupportedJointTypeError for anything but revolute and prismatic, and UnmarshalModelJSON propagates that error rather than skipping the joint, so the part ends up with no model frame at all. The part still draws here, model-less, keeping its mount offset and its configured geometry.

Why guard against a part named after another part's generated origin?

originName derives <part>_origin mechanically and has no idea whether some other part in the same reply is literally named that. A part cam and a part cam_origin produce the same key: cam's mount-offset frame and cam_origin's own bare-part frame both land on frames['cam_origin']. Whichever part is processed second would silently overwrite the first's mount offset and entire collision volume and reparent its descendants, and array order decides which "second" means, so it would not even be deterministic across replies. Both keys are checked so the collision is caught from either direction.

Why read each part in its own try?

Struct.toJson() throws on a non-finite number and on a Value with no kind set, and the throw escaped the loop: one malformed part meant no preview at all rather than one part missing. RDK's own marshal refuses infinities first, but its URDF converter does produce infinite limits for a continuous joint, so the value exists upstream and only that step stands between it and here.

Why does nodeName test for undefined and '' rather than truthiness?

Nothing on this route should emit a numeric node id. LinkConfig.ID and JointConfig.ID are Go strings, and the URDF converter reads jointElem.Name, an element's name attribute rather than its index. But kinematics arrives as a Struct, which can carry a NumberValue whatever RDK meant to put there, and a 0 that arrived as a JSON number is falsy. A truth test would drop that joint and everything below it.

Why are Go symbols named rather than cited by line?

Line numbers drift between releases, and the version in this repo's go.mod is not the one to check them against. v0.122.0 has no named frame type at all: namedFrame is absent from register.go and has no MarshalJSON, so dumping a frame system there fails outright with "not a registered Frame implementation". The named / inner_frame shape reproduced in this file, and the plan.json fixture with its 41 named frames, both postdate that version. go.mod pins what this repo's own Go compiles against, not what a machine runs, so this targets RDK v1.x.

What does this route not give you?

Two things, both consequences of what FrameSystemPart.ToProtobuf sends. A part can look model-less here while RDK has a real model for it, because kinematics is empty whenever SimpleModel.modelConfig is nil, which includes a model assembled from frames rather than parsed from a config. And primary_output_frame never arrives: RDK's model envelope carries it, ToProtobuf sends only model, and it is frameDescriptors' primary source for a model's end effector. On this route that branch cannot fire, so a branching model that declares no output frame will not get its end-effector remap here even though the replayer does.

Testing

pnpm exec vitest --run passes 870 tests across 75 files, up 50 tests and one file from the base branch. pnpm exec svelte-check reports 0 errors and 0 warnings.

The section that makes a consumerless module reviewable is equivalence with a real plan dump: it builds a frameSystemConfig out of plan.json's own model, synthesizes a frame system from it, and compares the result against what the dump itself produces. That is real signal rather than a restatement, because the two sides are genuinely different JSON: the flattened entry carries a quaternion orientation and a named geometry type where the model config carries a null orientation and an empty type, so the synthesized side has to survive the model-config encoding and still land on the same pose and shape.

It reads stronger than it is, though, and the synthesized frame system itself and a model that branches exist to cover the gap. Both sides of the equivalence run through buildFrameDescriptors, so a deterministic mistake in the descriptor builder appears identically on both and cancels out. Reversing the joint walk's sibling sort passes every assertion in that block.

Both behavior claims were checked by reverting them. Dropping the origin geometry for modelled parts fails 2 tests with expected undefined to be 'sphere'. Removing the jointless branch fails 2 with expected true to be false and expected undefined to be 'static'.

Cases the fixtures pin that a mutation could previously get away with:

  • The _origin pose. Holding the part's offset is the entire reason that frame exists, and every fixture went through a helper that defaults the pose to identity, so it could be discarded or replaced outright without a failure. Now asserted on the frame, through the descriptor builder, and as absent from the bare part frame.
  • The DH guard's position. Its fixture carries dhParams and no links or joints, which is what a real DH config looks like; a fixture that also declared a link makes both orderings of the guard pass.
  • The jointless stand-in must not repeat the envelope, which is the doubling the branch exists to prevent.
  • Links are emitted before joints.
  • Empty and missing node ids, an empty part name, an empty parent name, a node with no stated parent, and a part colliding with another part's generated origin name.
  • A mesh geometry, which is the one shape where the SDK's common.v1.Geometry and this package's actually differ, and so the only one where replacing the byte round-trip with a cast would have been caught. Every other fixture is a sphere, which a cast carries perfectly well.

@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 73cfb8e

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

Built to branch gh-pages at 2026-08-13 18:48 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 26s —— View job


Review

  • Read CLAUDE.md and repository conventions
  • Read all changed files
  • Run git diff for full context
  • Analyze correctness, patterns, and tests
  • Post inline comments for line-specific issues
  • Post review summary

This is a well-designed PR. The core logic in frameSystemToPlanFrames.ts faithfully mirrors RDK's flattening steps, the comments are precise about why each branch exists, and the tests are substantive — particularly the raw frame-system equivalence test that bypasses the descriptor builder and can catch mistakes the higher-level equivalence would cancel out.

Correctness

Pose.copy(undefined)new Pose().copy(transform.poseInObserverFrame?.pose) at line 194. When poseInObserverFrame is absent, Pose.copy receives undefined, which the doc-comment explicitly designates as "reset to identity." Safe. ✓

Exception orderingdrawableModelOf (line 189) is called before frames[origin] is written (line 194), so a Struct.toJson() throw propagates out of addPart with no partial writes in frames. The logic is correct; the test doesn't assert this (see inline).

ModelConfig vs ModelJsondrawableModelOf returns model.config stored on the model frame. modelOf in frameDescriptors.ts casts it to ModelJson. The fields modelJointColumns reads (links, joints) are present in both; no cast mismatch. ✓

DH guard placement — The kinematic_param_type === 'DH' check at line 85 correctly precedes the empty-links test. A DH config carries dhParams and no lists, so checking emptiness first would silently drop it as model-less with no warning. ✓

Emit order — Links before joints in frames (lines 221–237). JavaScript insertion order is preserved on string keys in V8, so childMap in buildFrameContexts sees them in that order, making the [0] end-effector fallback deterministic. ✓

Issues

MinornodeName (lines 63–65) is identical to jointColumns.nodeName which is already exported. The surrounding comment explaining the truth-test rationale should stay local, but the function body itself is a one-import change. Inline comment posted.

Test gap'loses only the part whose kinematics cannot be read' doesn't assert frames['bad_origin'] is also absent. The no-partial-writes guarantee is load-bearing; pinning it as an assertion costs one line. Inline comment posted.

Design

The DECODED_FRAME_TYPE = 'decoded' boundary is the right call. Encoding the already-decoded proto Geometry back into a GeometryConfig string just to route through the existing switch would lose mesh byte types — frameDescriptors.ts generates Geometry independently and its ArrayBuffer field type doesn't match the SDK's ArrayBufferLike. The toBinary/fromBinary round-trip on toLocalGeometry is the correct bridge.

The two-level test strategy — raw FrameSystemJson equivalence ('the synthesized frame system itself') and descriptor-builder equivalence ('equivalence with a real plan dump') — is smart. A deterministic mistake in the builder cancels on both sides of the descriptor test; the raw test catches it.

* carry a numeric `0` id, which is falsy but names a frame.
*/
const nodeName = (value: string | undefined): string | undefined =>
value === undefined || value === '' ? undefined : value

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.

nodeName is already exported from ./jointColumns with identical semantics and the same truth-vs-explicit rationale. Worth importing rather than re-defining — the one difference is that jointColumns's doc comment says "only the empty string, never a number" (which is accurate for the plan-dump route but not for this one), so the comment here would need to stay local even if the function itself moves.

Suggested change
value === undefined || value === '' ? undefined : value
const nodeName = (value: string | undefined): string | undefined =>
value === undefined || value === '' ? undefined : value

(no change to the body — just flagging the duplication; importing from ./jointColumns and keeping the comment here as inline context is the cleaner option)

Fix this →


expect(frames['ok']).toBeDefined()
expect(frames['also-ok']).toBeDefined()
expect(frames['bad']).toBeUndefined()

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 "no partial writes" invariant should be pinned here. If the try/catch ever moved or a throw happened after frames[origin] was written, bad_origin could leak into the scene as a dangling mount frame. The current ordering in addPart (model parsing at line 189, frame write at line 194) does protect against this, but the test doesn't assert it.

Suggested change
expect(frames['bad']).toBeUndefined()
expect(frames['ok']).toBeDefined()
expect(frames['also-ok']).toBeDefined()
expect(frames['bad']).toBeUndefined()
expect(frames['bad_origin']).toBeUndefined()

Fix this →

A joint whose parent names something the model never declares is a second root
to RDK, not a broken chain: buildModelFrameSystem seeds its walk with every
child whose parent is absent from transforms and hangs it off fs.World(). So
stray sorts against base rather than trailing the walk, and nothing is guessed
or warned about. Confirmed against go.viam.com/rdk, which builds this model and
reports its schema as [stray j1].
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