Skip to content

geometry decode fixes - #912

Merged
Devin T. Currie (DTCurrie) merged 5 commits into
fix/output-framefrom
fix/geometry-decode
Aug 13, 2026
Merged

geometry decode fixes#912
Devin T. Currie (DTCurrie) merged 5 commits into
fix/output-framefrom
fix/geometry-decode

Conversation

@DTCurrie

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

Copy link
Copy Markdown
Member

Fixes two geometry decoding bugs in the plan replayer. An unoriented collision shape on a rotated link is drawn rotated by the link's own rotation, and an STL collision mesh is dropped by the plan parser and misread as PLY by the renderer. Stacks on #910, base branch fix/output-frame.

Stack

  1. Read a plan model's output frame from where RDK writes it (read plan model output frame #910)
  2. This PR: Geometry decode fixes
  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. Add move preview to the MoveFrame plugin (Motion plan preview #908)
  18. Fill in the frames between planned waypoints (interpolation #930)

Frontend

  • geometryCenterInFrame ($lib/spatialJson) composes the geometry's local rotation unconditionally. It previously did so only when the geometry declared an orientation, which left an unoriented shape aligned to its parent rather than to its own link.
  • parseStlInput (new, src/lib/stl.ts) parses STL bytes through three's STLLoader. It cuts an exact ArrayBuffer out of a Uint8Array view so a subarray does not hand the loader its neighbors, and answers input shorter than an STL header with an empty geometry rather than throwing.
  • meshContentType and parseMeshInput (new, src/lib/mesh.ts) pick a parser from the declared content type, normalizing case, model/stl style prefixes and ; charset= suffixes. An unrecognized or absent type falls back to PLY.
  • Geometry and updateGeometryTrait in src/lib/ecs/traits.ts call parseMeshInput with the mesh's contentType. They previously called parsePlyInput for every mesh.
  • useDrawAPI's drawGeometry does the same, so all three mesh render paths read the content type rather than two of them ignoring it.
  • parseGeometry accepts any content type parseMeshInput handles and stores the normalized value on the proto Mesh. It previously required an exact ply and skipped everything else with a warning.

Why?

Why not just relax the content type check in the plan parser?

Because the check was load bearing. parsePlyInput is PLYLoader and nothing else, and the ECS geometry traits called it for every mesh without looking at the content type, so accepting STL at the parser alone would have turned a logged skip into a wrong render inside the viewer, far from the cause. The gate was the right guard for a viewer that only spoke PLY. Teaching the viewer STL is what removes it.

Why does PLY stay the fallback for an unrecognized content type?

It is what the renderer assumed before STL was handled at all, so making an unlabeled mesh an error would change behavior for data that renders correctly today. The plan parser does not depend on the fallback: it gates on meshContentType first and skips anything it does not recognize, so the fallback is only ever reached from the render path, where a named warning would arrive too late to be useful anyway.

Why normalize the content type when both RDK producers write a bare token?

They do: GeometryConfig writes string(fileType), which is only ever ply or stl, and the URDF loader picks one of the same two off the file extension and errors on anything else. The normalization is defense on a free string field of a proto this repo does not own, not a response to a shape anyone has observed. It stops at content types on purpose. meshes/ur20/base.stl is rejected, because that form belongs in mesh_file_path, and reading an extension here would commit us to reading one out of a package:// URI too.

Why does parseStlInput answer short or malformed input with an empty geometry instead of throwing?

STLLoader reads the triangle count as a uint32 at offset 80 before it checks the length, so 1 to 83 bytes throw a RangeError out of the DataView, and atob throws on malformed base64 ahead of that. PLYLoader answers both with an empty geometry. The callers are Geometry and updateGeometryTrait, which run inside an unguarded loop over every geometry on a resource with no error boundary above them, so one truncated mesh throwing would cost every mesh behind it. The guard sits at 84 bytes, where RDK draws the same line in newMeshFromSTLBytes.

Why was the rotation only wrong sometimes?

An absent orientation and an explicit identity quaternion make the same claim: the geometry is unrotated relative to its parent. Only the explicit form got R_frame⁻¹ applied, so the two forms disagreed by exactly the link's own rotation.

That the pose is parent relative is RDK's convention rather than a guess about it. RDK has two static frame kinds that differ precisely in which end of the frame's transform the geometry pose is measured from: staticFrame.Geometries returns geometry.Transform(NewZeroPose()), the stored pose unchanged, while tailGeometryStaticFrame.Geometries returns geometry.Transform(sf.transform). frame.go says it directly, that a tail geometry frame is "a static frame whose geometry is placed at the end of the frame's transform, rather than at the beginning". So a plain static frame's geometry pose is parent relative, its link local form is T_frame⁻¹ ∘ G, and the rotation half of that is R_frame⁻¹ when the geometry declares nothing.

The same evidence says the two frame kinds must not be treated alike, and they are not. parseGeometry only undoes the parent frame when it is handed a framePose, and the only caller that passes one is the model link path. Top level tail_geometry_static frames keep their pose as is, which is correct, and they are not rare: all four captures carry them, 19 in plan.json alone.

Why fix a rotation branch no captured plan reaches?

Because it is one line and the branch is reachable by construction. RDK's marshaller always writes an orientation, since OrientationOffset is a struct value whose omitempty does nothing and NewGeometryConfig assigns it unconditionally, so no marshalled dump takes this path. Hand authored kinematics do. A bare geometry on a link that is itself rotated appears in ur20.json (base_link, wrist_1_link), xarm6.json and lite6.json (gripper_mount in both). ur20.json's wrist_1_link is the clearest, a capsule with a translation and no orientation on a link turned by euler_angles.

Why does a mesh from a live machine still route to PLY?

Because RDK says it is PLY. Mesh.ToProtobuf hardcodes ContentType: "ply", and newMeshFromSTLBytes keeps the raw STL it was handed, so a live part sends STL bytes under a ply label. STL therefore decodes from the plan dump path, where GeometryConfig writes the true fileType, and from the draw service. Guessing the format from the bytes would mean ignoring a content type RDK stated, which is worse than the mislabel, so this belongs upstream rather than in a client side sniffer.

Testing

pnpm exec vitest --run passes 693 tests across 69 files, up from 655 across 67 on fix/output-frame. svelte-check reports 0 errors and 0 warnings across 9963 files.

New src/lib/__tests__/mesh.spec.ts (30 tests) covers content type normalization, ASCII and binary STL and PLY parsing to real vertex counts, the PLY fallback, empty meshes, base64 input in both STL encodings, a mesh held in a subarray, and truncated input. Its STL fixture is binary on purpose: STLLoader classifies ASCII with a regex, so an ASCII fixture parses out of an oversized buffer and would pass with the subarray cut deleted.

New src/lib/__tests__/meshGeometryTrait.spec.ts (3 tests) drives a Geometry proto with a mesh case through traits.Geometry and traits.updateGeometryTrait out to a BufferGeometry with real vertices. A regression to parsePlyInput is invisible without it, because PLYLoader answers STL bytes with an empty geometry rather than throwing, so the entity still reaches the world.

In build-frame-descriptors.spec.ts, treats an absent geometry orientation the same as an explicit identity asserts the two forms agree and that the shared answer is the link's rotation undone rather than identity. reads a mesh declared as %s covers stl, STL, model/stl and ply; charset=binary. The existing skip table keeps obj and a missing content type skipping.

Each production change was checked by reverting it and naming the test that catches it:

reverted now fails
the short input guard on the bytes branch returns an empty geometry for a 1/19/83 byte stl rather than throwing
the short input guard on the base64 branch returns an empty geometry for an empty string / a truncated base64 payload
the subarray cut, passing the whole backing buffer parses a binary stl mesh held in a subarray
the unconditional rotation in geometryCenterInFrame treats an absent geometry orientation the same as an explicit identity
parseMeshInput back to parsePlyInput in the traits all 3 in meshGeometryTrait.spec.ts

@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 56eca67

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 4, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-13 17:55 UTC

@viam-overwatch

Copy link
Copy Markdown

Hey Devin T. Currie (@DTCurrie) — CI is green and no reviewer is assigned yet. Could you request one when you have a chance?

Auto-comment from overwatch. Will not re-nudge for 7 days.

@DTCurrie
Devin T. Currie (DTCurrie) force-pushed the fix/geometry-decode branch 2 times, most recently from 504223c to b1bdd31 Compare August 11, 2026 14:09
@DTCurrie
Devin T. Currie (DTCurrie) force-pushed the fix/geometry-decode branch 2 times, most recently from 4c1dcd3 to 55ef0ee 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 6m 46s —— View job


Review

  • Gather context (CLAUDE.md read, PR diff)
  • Read changed files (stl.ts, mesh.ts, spatialJson.ts, traits.ts, useDrawAPI.svelte.ts, build-frame-descriptors.ts, test files, ply.ts baseline)
  • Analyze correctness, edge cases, tests
  • Post inline comments
  • Post review summary

No bugs found. The two fixes are correct and minimal. Inline comments are observations, not action items.

Rotation fix (spatialJson.ts)

The one-liner change is the right call. An absent orientation and an explicit identity quaternion are semantically identical — "unrotated relative to the parent" — but only the explicit form previously got R_frame⁻¹ applied. Making it unconditional is the correct fix: quatFromJson writes identity when it finds nothing, and tmpQInv * identity = tmpQInv, so the frame rotation is always undone. The test at build-frame-descriptors.spec.ts:654 nails this precisely: it checks not just that the two forms agree, but that the shared answer is -90° rather than 0 — the identity-rotation trap.

STL parser (stl.ts)

Correctly mirrors parsePlyInput's patterns:

  • Module-level singleton loader (consistent with ply.ts:4)
  • String → base64, Uint8Array → bytes (same conventions)
  • Subarray fix using buffer.slice (equivalent to ply.ts's new Uint8Array(mesh).buffer)

The minimum-bytes guard at 84 is the key divergence from PLY: STLLoader reads the triangle count as a uint32 at offset 80 before checking length, so anything shorter throws from a DataView. PLY handles short input gracefully. The guard is necessary and documented.

The string path (atob → binary string → stlLoader.parse) works because STLLoader.ensureBinary converts char codes to bytes, so the binary string from atob round-trips correctly. See inline on L26.

Content type normalization (mesh.ts)

meshContentType's split-chain is robust. The test matrix is exhaustive: bare tokens, MIME prefixes, charset suffixes, whitespace, empty string, undefined, and — critically — file paths that must not match. The path rejection (meshes/base.stl'base.stl', not 'stl') is correct because .at(-1) gives 'base.stl', not 'stl'.

One pre-existing asymmetry worth knowing (outside this PR's scope): parsePlyInput does not guard malformed base64 — it calls atob directly and would throw. parseStlInput catches and returns empty geometry. This doesn't affect current callers (plan parser pre-validates, traits receive Uint8Array), but if parseMeshInput ever gains a PLY caller passing raw strings, the throw-vs-graceful divergence could matter.

Tests

Test design is particularly strong:

  • binaryStl fixture is binary on purpose (explained in the comment) — ASCII would silently pass with the subarray guard deleted
  • meshGeometryTrait.spec.ts exists because PLYLoader returns empty geometry on STL bytes rather than throwing, meaning a regression to parsePlyInput is invisible without it
  • The regression table in the PR description maps every production change to its catching test — unusually rigorous

Summary

LGTM. The fixes are correct, tests are thorough, and the PR description fully documents the RDK conventions this code encodes (static vs tail-geometry frame kinds, content type provenance). Ready to merge once the stack lands.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished Devin T. Currie (@DTCurrie)'s task in 5m 11s —— View job


Review: geometry decode fixes

  • Read CLAUDE.md and gather context
  • Read all changed files
  • Run git diff for full picture
  • Review correctness, error handling, API design, patterns, tests
  • Post inline comments and summary

Summary

Two clean, focused bug fixes with thorough tests and one of the most detailed PR descriptions I've seen. No correctness issues. A few minor observations below.


src/lib/spatialJson.ts — rotation fix

The math is correct. Removing the if (quatFromJson(...)) guard means an absent orientation now correctly contributes R_frame⁻¹ rather than leaving the center at identity. quatFromJson writing identity for a missing orientation makes the function's total effect R_frame⁻¹ · I = R_frame⁻¹, which is exactly right for a geometry that declares itself unrotated relative to its parent.

The new test (treats an absent geometry orientation the same as an explicit identity) proves both equality with the explicit-identity form and that the shared answer is the link's rotation undone — that's the right level of assertion.


src/lib/mesh.ts — content type normalization

The one-liner

const value = (raw ?? '').toLowerCase().split(';')[0]?.trim().split('/').at(-1)

is compact but traced through every documented case:

input result
'ply' 'ply'
'STL' 'stl'
'model/stl' 'stl'
'application/ply; charset=binary' 'ply'
'meshes/ur20/collision/base.stl' 'base.stl' → undefined ✓
'package://arm/link_1.stl' 'link_1.stl' → undefined ✓

File-path rejection falls out naturally: extensions like base.stl or link_1.stl don't equal 'stl' exactly.


src/lib/stl.ts — STL parser

String path is correct. stlLoader.parse(decoded) accepts binary strings from atob because STLLoader.parse calls ensureBinary first, which converts a binary string to ArrayBuffer via charCodeAt. This is well-defined Three.js behavior, not a coincidence.

Subarray handling uses mesh.buffer.slice(...) vs parsePlyInput's new Uint8Array(mesh).buffer — both produce a fresh copy, just different idioms. No bug, minor inconsistency.

See inline comment on line 26 for the one unguarded edge case (valid-length, corrupt triangle count) that carries through both parsers.


Tests

mesh.spec.ts: The binary fixture is exactly the right choice — the comment explaining why is important and should be preserved. See inline comment on line 55 for a minor fragility in the base64 helper.

meshGeometryTrait.spec.ts: The rationale doc-comment (PLY answers STL bytes with empty geometry rather than throwing) is the key insight that makes this spec necessary and non-redundant. Good.

build-frame-descriptors.spec.ts: The parameterized mesh skip table (reads a mesh declared as %s and the updated skip table) cover the normalization path end-to-end. The regression table in the PR description mapping each production change to the test that catches it is unusually useful.


Pre-existing asymmetry (not this PR's concern)

parsePlyInput calls atob(mesh) with no try/catch for malformed base64, while parseStlInput wraps it. Since the callers are ECS trait loops with no error boundary, parsePlyInput could bubble a DOMException if it ever receives a malformed base64 string. Follow-up candidate.


Verdict: LGTM with minor nits. The rotation fix and mesh content-type path are both correct, the test strategy is smart, and the PR description is thorough enough to serve as the authoritative reference for future readers.

Comment thread src/lib/stl.ts Outdated
} catch {
return new BufferGeometry()
}
return decoded.length < STL_MIN_BYTES ? new BufferGeometry() : stlLoader.parse(decoded)

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 string-path length guard and the atob try/catch together catch everything that STLLoader throws by design. Worth noting one unguarded gap that carries through to the binary path too: for data ≥84 bytes where the triangle-count field at offset 80 claims more triangles than the body actually contains, parseBinary reads out of bounds in DataView and throws a RangeError. parsePlyInput has the same gap for corrupt binary PLY headers, so this is consistent — just worth knowing if callers ever get untrusted mesh data rather than RDK-produced blobs.

Comment thread src/lib/__tests__/mesh.spec.ts Outdated
return new Uint8Array(buffer)
}

const base64 = (data: Uint8Array) => btoa(String.fromCodePoint(...data))

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.

Nit: String.fromCodePoint(...data) spreads the array into the call stack. Fine for this 134-byte fixture, but it'll RangeError on anything beyond ~65 k bytes. Array.from(data, c => String.fromCharCode(c)).join('') is the safe form if this helper is ever reused for larger fixtures.

Suggested change
const base64 = (data: Uint8Array) => btoa(String.fromCodePoint(...data))
const base64 = (data: Uint8Array) => btoa(Array.from(data, (c) => String.fromCharCode(c)).join(''))

Comment thread src/lib/stl.ts Outdated
} catch {
return new BufferGeometry()
}
return decoded.length < STL_MIN_BYTES ? new BufferGeometry() : stlLoader.parse(decoded)

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.

atob returns a binary string where each character's code point is one byte, so decoded.length equals the byte count here — correct for the STL_MIN_BYTES check. Three.js's STLLoader.ensureBinary then converts it back via charCodeAt, so binary STL round-trips correctly through the string path.

Comment thread src/lib/stl.ts
// The loader takes a whole ArrayBuffer, so a view into a larger one has to be cut out first.
// Getting this wrong is silent on binary STL: `isBinary` stops matching and the parse returns
// nothing.
const whole = mesh.byteOffset === 0 && mesh.byteLength === mesh.buffer.byteLength

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.

ply.ts does the same via new Uint8Array(mesh).buffer — both create a fresh ArrayBuffer from the view's slice. slice is slightly more direct. Consistent intent, defensible divergence in spelling.

Comment thread src/lib/mesh.ts
* `ply` or `stl`, so the folding and trimming are defense on a proto this repo does not own.
*/
export const meshContentType = (raw: string | undefined): MeshContentType | undefined => {
const value = (raw ?? '').toLowerCase().split(';')[0]?.trim().split('/').at(-1)

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 chain handles 'model/stl', 'application/ply; charset=binary', and bare 'stl' all in one expression. The test table confirms path-like strings (meshes/base.stl) fall through correctly because .at(-1) on 'base.stl' does not equal 'stl'.

Comment thread src/lib/spatialJson.ts Outdated
}
// Unconditional: an absent orientation means identity in the parent's frame, which is still
// R_frame⁻¹ once expressed locally. `quatFromJson` writes identity when it finds nothing.
quatFromJson(geoOrient, tmpQGeo)

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 is the crux of the rotation fix. Before: absent orientation skipped setFromQuaternion, leaving the center with no rotation applied, so the geometry was placed in the parent frame rather than the link frame. Now quatFromJson writes identity when it finds nothing, and tmpQInv * identity = tmpQInv — the frame rotation is undone regardless of whether the geometry declared one explicitly.

* Binary on purpose: `STLLoader` classifies ASCII by regex, so an ASCII fixture parses out of an
* oversized buffer and the subarray and short-input cases below pass with their guards deleted.
*/
const binaryStl = (triangles = 1): Uint8Array => {

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.

Smart fixture choice. ASCII STL is classified by a regex in STLLoader, so ASCII bytes would pass the subarray guard even when the fix is absent — the test would be vacuously green. Binary forces the isBinary path, where the wrong offset is silent (returns empty geometry) rather than throwing.

* Not covered by `mesh.spec.ts`: a regression to `parsePlyInput` here still puts an entity in the
* world, since `PLYLoader` answers STL bytes with an empty geometry rather than throwing.
*/
describe('mesh geometry reaches the trait layer', () => {

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.

Good motivation for a separate spec file. PLYLoader returns empty geometry on STL bytes rather than throwing, so a regression to parsePlyInput in the trait would produce an entity with no vertices — indistinguishable from success without this coverage.

@DTCurrie
Devin T. Currie (DTCurrie) merged commit f4cad4d into main Aug 13, 2026
8 of 12 checks passed
@DTCurrie
Devin T. Currie (DTCurrie) deleted the fix/geometry-decode branch August 13, 2026 17:55
@claude claude Bot mentioned this pull request Aug 13, 2026
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.

2 participants