match rdk mesh data decoding - #920
Conversation
🦋 Changeset detectedLatest commit: 4a3b4f1 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 |
|
bf3d5b0 to
8544c11
Compare
1991723 to
a1de5b1
Compare
204b109 to
ba31d12
Compare
ba31d12 to
abf2420
Compare
|
Claude (@claude) review |
|
Claude finished Devin T. Currie (@DTCurrie)'s task in 7m 42s —— View job Review
This is a well-constructed fix. The PR description is unusually complete — every design decision has a clear rationale. Inline comments cover two minor nits; nothing blocks merging. Correctness
Order change in TestsSignificant improvements over the previous state:
Nits (inline)
|
| it.each([ | ||
| ['an unhandled content type', { mesh_content_type: 'obj', mesh_data: btoa('solid\n') }, 'obj'], | ||
| ['a missing content type', { mesh_data: btoa('ply\n') }, 'content type'], | ||
| ['empty mesh data', { mesh_content_type: 'ply', mesh_data: '' }, 'no mesh_data'], |
There was a problem hiding this comment.
Nit: the test label "empty mesh data" and the reason 'no mesh_data' point in different directions. Internally '' returns 'absent' (caught by the early raw === '' guard in meshBytes), so the warning says "carries no mesh_data", not "carries empty mesh_data". A future maintainer eyeballing the table might expect 'empty mesh_data' here (mirroring the [] row below) and be confused when it fails.
Renaming the case to 'an empty string' would match how the code thinks about it:
| ['empty mesh data', { mesh_content_type: 'ply', mesh_data: '' }, 'no mesh_data'], | |
| ['an empty string', { mesh_content_type: 'ply', mesh_data: '' }, 'no mesh_data'], |
| // here rather than draw nothing later. | ||
| const contentType = meshContentType(declared) | ||
| if (!contentType) return skip(`unsupported mesh content type "${declared ?? ''}"`) | ||
|
|
There was a problem hiding this comment.
The original code checked data (!meshData) before content type. The order is now reversed — content type is validated first. This is fine when only one is wrong, but if both are simultaneously wrong (absent mesh_data and an unsupported content type), the user sees "unsupported content type" and must fix it before discovering there is no data. There are no tests for the both-wrong case because every failing fixture has exactly one bad field.
Not a blocking issue, but worth noting since the original order produced the arguably more actionable first error.
abf2420 to
4a3b4f1
Compare
4a3b4f1 to
9cdf12b
Compare
Decodes
mesh_datain both of the shapes RDK sends it, so a mesh that reaches the client throughframeSystemConfigrather than through a plan dump draws instead of vanishing. Stacks on #919.Stack
$lib/motion(make motion utils reusable #917)MoveFrameplugin (Motion plan preview #908)Frontend
meshBytesturns a rawmesh_datavalue into bytes, or into one of three named problems:absent,empty,unreadable. It reads a number array or a base64 string and refuses anything else. A number array is accepted whole or refused whole; a string goes throughprotoBase64.decinside atry, sincedecthrows a bareErrorthatloadPlanwould otherwise report as an unparseable plan rather than as one unreadable shape.meshcase inparseGeometrycallsmeshBytesinstead of decoding inline, and maps each problem to its ownskip, so the warning both names the frame and says which of the three happened.inferGeometryTypereturnsmeshwhenmesh_dataormesh_content_typeis set. It previously read onlyx/y/z,landr, none of which a mesh sets, so an untyped mesh fell past the whole chain into the empty-type arm that means "no geometry" and dropped with no warning at all, while every other unreadable mesh here gets one that names its frame.Why?
Why does the same field arrive in two shapes?
The route decides, not the data. A plan dump reaches the client through
SimpleModel.MarshalJSON, so Go'sencoding/jsonwrites the[]byteas base64.frameSystemConfigreaches it throughprotoutils.StructToStructPb, which reflects over the struct rather than marshalling it, and whosemarshalSlicewalks a slice element by element. The same field therefore arrives as an array of numbers.Go itself never needs an equivalent of
meshBytes, which is why the asymmetry is easy to miss from that side:encoding/jsonunmarshals both a base64 string and a number array into a[]byte. Only a decoder written by hand has to know there are two shapes.Why refuse a whole array rather than filter the bad elements out of it?
Because dropping one element shifts every byte after it, and a byte-shifted mesh does not fail loudly. A binary STL fails
isBinary's size check, falls through to the ASCII parser, whosefacetregex matches nothing, and yields an emptyBufferGeometrywith no error at all. A collision volume that silently renders as nothing is worse than one that was refused and named in a warning.encoding/json, the decoder on the other end of this contract, rejects every one of these cases too: a non-number element, a value past 255, a negative, a non-integer.Why does the length check sit after the branch rather than inside the array arm?
Because both shapes can carry nothing, and only one of them looks like it can.
[]is truthy where''is falsy, which reads as though the array is the arm that needs a length check and the string arm is already covered. But only the literal empty string is falsy.protoBase64.decignores whitespace and tolerates missing padding, so'=','====',' 'and'\n'all decode to zero bytes without throwing, and aUint8Array(0)is exactly as truthy as[]. Each of those would otherwise build the entity this guard exists to prevent, one that renders nothing and still costs a draw pass.Why keep three skip reasons instead of one message?
Because they have different causes and different owners.
absentandemptysay the robot's config carries no mesh, which is a question for whoever wrote that config.unreadablesays this decoder was handed bytes it could not read, which is a question for this file. That distinction is what separates a mesh arriving in the array shape from a mesh whose data is genuinely corrupt, and reading only base64 makes the first look exactly like the second. Collapsed into one string, the two are indistinguishable at the console.Why does
inferGeometryTypeneed a mesh branch at all?Because a mesh is the one geometry the existing chain cannot see, and it is the one where falling through is silent rather than warned. An authored config can omit
typeentirely, which this file already pins for a sphere, and nothing stops such a config from being a mesh. Nothing reaches it today:buildFrameDescriptorsskipsmodelframes, and no capture pairs an absenttypewith mesh data. It costs two comparisons and closes the gap before a caller that reaches it exists.Testing
pnpm exec vitest --runpasses 752 tests across 70 files, up 17 tests and no new files from the base branch.pnpm exec svelte-checkreports 0 errors and 0 warnings.I checked each behavior claim by reverting it alone. Dropping the number-array branch fails
reads mesh data delivered as a number array, as frameSystemConfig sends it. Filtering the array instead of refusing it fails all fourrefuses a number array with ...cases. Moving the length check inside the array arm fails the four base64-decodes-to-nothing cases. Collapsing the three reasons into one fails four of the sixskips a mesh with ..., and says whichcases. Dropping theinferGeometryTypefallback failsreaches the mesh branch, not the silent no-geometry arm, when type is absent.Three things in these specs are not what they look like:
toBeNull(), so deletingconsole.warnfromparseGeometry'sskiphelper outright, which also covers the unsupported-geometry-type branch, passed the entire suite. Each case now asserts the reason string and the frame name, the frame name because a warning that cannot be traced back to a frame is not actionable on a rig carrying forty geometries.it.eachagainst the sameTextEncoderoutput, so a failure prints the bytes rather than collapsing to a barefalse.reaches the mesh branch, not the silent no-geometry arm, when type is absentasserts thatwarnwas never called, and that holds only because ofclearMocks: trueinvite.config.ts. The spy is installed once at describe scope and is never cleared by hand, and the test immediately before it in the block does warn.parseMeshInput's subarray handling is now pinned for both loaders.parses a binary %s mesh held in a subarrayruns overstlandply, the PLY side against a hand-built binary fixture with abinary_little_endianheader, three float vertices and one face list. The fixtures are binary on purpose: an ASCII mesh survives the padding either way, since PLY decodes text from the view itself and STL's ASCII parser matchesfacetblocks by regex, which leading NUL bytes do not disturb. Only the binary path reads the underlying buffer positionally, so only a binary mesh at a nonzero offset distinguishes a correct slice from.buffer. The ASCII pair is kept beside it so a change to either loader's ASCII-versus-binary sniffing cannot quietly break the text route, but it is not what guards the offset. The failure being guarded against is silent in both loaders: an unsliced view starts them four bytes early and each answers with an emptyBufferGeometryrather than a throw.parse-plan.spec.ts'sbuilds every geometry in the capture without skipping onestill passes, so the captured plan takes no new skip path.