match rdk joint numbering - #918
Conversation
🦋 Changeset detectedLatest commit: 491f878 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 |
|
7130e25 to
fc52705
Compare
fc52705 to
29b5a07
Compare
29b5a07 to
6b95da9
Compare
6b95da9 to
1b22fed
Compare
1b22fed to
97d03fa
Compare
|
Claude (@claude) review |
|
Claude finished Devin T. Currie (@DTCurrie)'s task in 4s —— View job Review
Correctness ✅The BFS implementation in
One trace I checked by hand: the Tests ✅Excellent coverage. Several design choices worth calling out positively:
API Design
The new Nits (inline comments posted)
SummaryThe algorithm is correct, well-tested, and the PR description's claim that "RDK's answers were taken from RDK rather than derived by reading it" is the right epistemic standard for this kind of reimplementation. The |
| joints: joints.map((joint, index) => ({ | ||
| ...joint, | ||
| parent: index === 0 ? 'world' : joints[index - 1]!.id, | ||
| })), |
There was a problem hiding this comment.
Nit: joints[index - 1]!.id asserts non-null on a field that is now optional (JointJson extends ModelNodeJson where id?: string). Every current chain() call passes joints with explicit string ids, so this is safe in practice, but it's worth noting the gap — calling chain({ mimic: { joint: 'a' } }) (no id) would silently produce parent: undefined for the next joint, which nodeName then maps to MODEL_ROOT. Not a bug today; just fragile for future test additions.
|
|
||
| const indices = (model: ModelJson): Record<string, number | undefined> => { | ||
| const columns = columnsOf(model) | ||
| return Object.fromEntries((model.joints ?? []).map((j) => [j.id, columns.get(j.id!)?.index])) |
There was a problem hiding this comment.
Same pattern: j.id! asserts non-null on an optional field. Safe because every caller of indices passes models whose joints have explicit ids, but the non-null assertion will mask a TypeScript error if a future test passes a model with unnamed joints through this helper.
97d03fa to
491f878
Compare
modelJointColumns takes (model, name) and returns {order, columns} as of this
PR, so the mimic test inherited from the mimic-joints PR has to build its input
with columnsOf(chain(...)) rather than calling it with a bare joints array.
The output-frame test expects whichever child the shared armed() helper lists
first under gripper_rot. That helper now lists extra_link first, so that four
of the rung tests below it stop passing with their own rung deleted.
491f878 to
a5474b5
Compare
Numbers a model's joints the way RDK does, so a trajectory step drives the joint it was meant to. Stacks on #917.
modelJointColumnsreadmodel.jointsin declaration order. RDK does not:NewModelWithMimicsseeds a model's input schema by walking the model's own internal frame system breadth-first from its root, visiting each node's children in sorted order and skipping mimic frames while it numbers. The two agree only for a model whose links and joints happen to be declared down its own chain, which an xArm6 and every capture in this repo are. For anything branched, each joint is driven from a different joint's value and the arm folds through itself.Stack
$lib/motion(make motion utils reusable #917)MoveFrameplugin (Motion plan preview #908)Frontend
modelJointColumns(model, modelName)takes the whole model config rather than just itsjointsarray, because the walk needs the links: a joint's parent is usually a link, so the chain cannot be reconstructed from the joints alone. It returns{ order, columns }instead of a bareMap.orderis every joint id in schema order, mimics included.columnsis keyed by joint id, and a mimic's entry addresses its source's column plus the linear map to apply, unchanged from before.childrenOfmap over links and joints together, sorts each sibling list, and visits breadth-first fromMODEL_ROOT, the model's internalworld. Numbering then runs overorder, so a mimic shifts every joint below it rather than every joint declared after it.MODEL_ROOT, matchingbuildModelFrameSystem. Only a parent cycle can now leave a joint unreached; those are appended in declaration order and warned about by name.nodeNamemaps bothundefinedand''toundefined, and every id and parent read in this file goes through it. It is exported, becausesoleLeafOfneeds the same filter.soleLeafOfinframeDescriptors.tsnow filters ids and parents throughnodeName. An unnamed node previously counted as a second unclaimed leaf and demoted a model that has a real sole leaf to "more than one".buildFrameContextsreadsorder.at(-1)when deciding where a model with no declared end effector hangs its tool, where it previously readmodel.joints.at(-1).ModelJsonandModelNodeJsonare declared injointColumns.ts.frameDescriptors.tsdrops the localModelNodeit was declaring and typesmodelOf,soleLeafOfandmodelOutputFrameagainstModelJsoninstead ofRecord<string, unknown>.frameDescriptors.spec.tsgained thelinksandparentfields a real model carries. They declared bare joints with no chain, which only passed because declaration order happened to be the answer.Why?
Why is this one function and not two?
The two halves are separable to describe and not to implement. Skipping mimics is what makes the columns contiguous, and the walk is what decides who gets skipped past. Written apart, either one alone produces a wrong answer that looks right on RDK's own test models, because in both of them the mimic is the last joint and nothing shifts behind it.
Why does a node with an unknown parent root at the model rather than count as disconnected?
Because that is what RDK does, and the difference is a different set of columns rather than a warning.
buildModelFrameSystemseeds its queue with every child whose parent is absent from the transforms it collected, and attaches those tofs.World(). So a joint parented to a name that does not exist is an ordinary root-level frame with a real position in the walk, and it sorts against the model's actual base. Reading it as disconnected instead pushed it to the end of the order.I verified this rather than reasoning about it. The exact model this PR's test uses, given an
output_framesso it clears RDK's single-end-effector check, builds throughUnmarshalModelJSONand reportsMoveableFrameNames()of[orphan attached]. The test assertsorphanfirst.Why filter an empty id instead of treating it as a name?
LinkConfig.IDandJointConfig.IDare Go strings with noomitempty, so a node that declares no id arrives as"", not as a missing key. Left in the tree, every unnamed node collides on that one key and claims the others' children. InsoleLeafOfthe failure is quieter and worse: an unnamed node is unclaimed by definition, so it reads as a second leaf, and a model that has exactly one real leaf stops resolving its end effector and falls through to the last-joint rule instead. Both readers now go throughnodeName, which is why it is exported rather than local.Why does the end-effector fallback change at all?
That branch answers "what hangs off the last joint" for a model that declares no output frame, and
model.joints.at(-1)is declaration order. The tool hangs off the joint the walk ends on. The branch is a floor rather than a live path, since a model with neither an output frame nor a single leaf is one RDK will not marshal, but the two readings disagree the moment a model branches and the old one had no way to be right.Why keep a fallback for unreached joints when RDK refuses to build a cycle?
ErrCircularReferencemeans the only shape that can strand a joint here cannot come off a real machine, so this is a floor under malformed input rather than a guess about a real one. It stays because the alternative is dropping the joint, which silently takes its entire subtree out of the drawing. Appending the strays in declaration order keeps them addressable and the warning names each one, the model, and the fact that their columns are a guess.Testing
pnpm exec vitest --runpasses 727 tests across 70 files, up 15 tests and no new files from the base branch.pnpm exec svelte-checkreports 0 errors and 0 warnings.None of the four captured plan dumps contains a mimic joint, and on all 29 model frames across them the walk order, the declaration order and RDK's own
internal_fsagree. Nothing in this repo's fixtures renders differently, so all of the new coverage is synthetic and the fixtures are built to be shapes a machine could actually send.RDK's answers here were taken from RDK rather than derived by reading it. A small Go program builds each fixture through
UnmarshalModelJSONand printsMoveableFrameNames(), which is the schema order this function reproduces:[alpha_joint zeta_joint beta_joint][alpha_joint zeta_joint beta_joint], 3 DoF for 4 joints[orphan attached]output_framesneed exactly one end effectorThat last row is why the invented models declare an
output_framesthis function never reads. Each has two leaves, andParseConfigfails a two-leaf model with no declared end effector, so without the field they would be shapes no robot can produce. RDK also rejects a mimic joint that declares its own limits, which is worth knowing when writing one by hand.The real gate is
a model whose declaration order is not its chain order: two branches off a shared base,zetadeclared beforealpha, with a joint under each branch.gamma_jointis the one that earns its place. With a single joint below the last-sorted branch, a depth-first walk produces the same sequence and the fixture pins nothing; with two, a column index depends on breadth-versus-depth rather than only on theorderarray.hangs the camera off the last joint of the walk, not the last one declaredis the only test that separatesorder.at(-1)frommodel.joints.at(-1). Without it the entireframeDescriptors.tshalf of this PR could be reverted with the whole suite still green: exactly one test reached that line, and its model produced anorderof one element, where every reading agrees.The pre-existing mimic tests were rewritten through a
chain()helper that parents each joint to the one before it. That is what lets them keep their old expected values: it holds schema order and declaration order equal so the mimic rules are the only thing under test.The rest of what the new fixtures pin, each of which a mutation could previously get away with:
queue.pushversusqueue.unshift.nodeName's empty-string half, which had no coverage at all: it could be replaced with the identity function, or deleted and inlined, with nothing failing.[motion]prefix or the explanation that the columns are a guess.