fix(urdf,physics): keep each link's collision decomposition - #787
Conversation
The URDF importer collapsed every link to a single geometry taken from its first `<visual>`, and the physics layer derived both the collider and (absent an authored `<inertial>`) the mass properties from that one mesh. URDF links routinely declare several `<collision>` elements — a convex decomposition — and those were ignored entirely. A cart body approximated by one mesh instead of its 8-piece decomposition gets the wrong contact surface, and the pieces exist precisely because the single mesh is a bad collider. - `PartDef::colliders: Option<Vec<NodeId>>` carries the collision roots as a *list*. Not a union node: unioning the pieces back together throws away the decomposition (and `CsgOp::Union` is a boolean, which would weld them). Absent means "the collider is `root`" — what every non-URDF authoring path wants. - The reader emits one DAG root per `<collision>`, sharing the primitive re-centering and `<origin>` placement with `<visual>` via a new `geometry_subtree` helper. `<visual>` still drives the rendered root; collider roots are never scene entries. - `PhysicsWorld` evaluates each collider root into its own shape and fills phyz's `Body::collisions`, with `Body::geometry` mirroring the first so single-shape consumers (and the GPU contact pass) keep working. Ground contact gathers candidates across all of a body's shapes before capping the manifold, so a body resting on two of eight pieces keeps support under both. - An unresolvable collider piece is skipped rather than fatal; if none resolve, the part's own geometry stands in as before. - Mass properties deliberately stay on the part's own geometry: the decomposition describes the contact surface, not the material distribution, and its pieces routinely overlap. Prerequisite fixed along the way: quick-xml's serde adapter only folds *contiguous* repeated siblings into a `Vec`, so a link interleaving `<visual><collision><visual>` — exactly what a decomposed link looks like — failed the whole parse with `duplicate field "visual"`. The existing robot-level reorder pass is now generalized (`reorder_children`) and recurses into every `<link>`. `validate_document` walks the new collider refs too; nothing else points at those nodes, so a dangling one would otherwise surface as a physics failure far from its cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Choji review — Looks good The implementation is sound: the IR extension, URDF reader, physics world, and validator all compose correctly, and the test suite pins every invariant the PR description claims. No correctness defects found. No findings — looks good. Reviewed |
The XLeRobot work landed on main while this branch was in flight, and it brought its own fix for the same quick-xml prerequisite: a link interleaving `<visual>`/`<collision>` failed the whole parse with `duplicate field "visual"`. Main's `normalize_link_child_order` + `group_children_by_tag` win over this branch's `reorder_children` generalization. They are equivalent in effect, and main's handles a self-closing `<link .../>` by returning explicitly on `Event::Eof` rather than catching an `InvalidFormat` from the reorder — so a genuinely malformed link body still propagates its error instead of being passed silently to serde (Choji nit). Kept from this branch: `PartDef::colliders`, the reader's per-`<collision>` `geometry_subtree` roots, and the physics layer's per-piece collider shapes. Two `PartDef` literals new on main pick up `colliders: None`. The vendored `third_party/xlerobot/xlerobot.urdf` now exists, so the regression test the task asked for runs against the real asset: `base_link` keeps all 8 `<collision>` pieces and `Moving_Jaw` all 3, each resolving to its own `_part*.ply` convex mesh rather than falling back to the `.STL` the link renders as. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Merged Nit 1 — self-closing link tags swallow the reorder error. Fair, and fixed by deferring to main's implementation rather than mine. Main landed Nit 2 — collision-only link renders a collision piece. This is intended, and unchanged from before this PR: the pre-existing reader already did The actionable half of the finding was right though: the path had no scene-entry assertion. Bonus from the merge: Full workspace 🤖 Addressed by Claude Code |
Dismissing prior approval to re-evaluate 7c099ee.
Records main as an ancestor. The tree is unchanged: 7c099ee already carried the fully resolved merge content, but a `git stash` round-trip during conflict resolution dropped MERGE_HEAD, so that commit landed with a single parent and GitHub kept reporting a conflict. `-s ours` keeps the already-correct tree and adds the missing parent link. Resolution recap (unchanged from 7c099ee): main's `normalize_link_child_order` + `group_children_by_tag` supersede this branch's `reorder_children` generalization — equivalent in effect, and main's returns explicitly on `Event::Eof` for a self-closing `<link/>` instead of catching an `InvalidFormat`. `PartDef::colliders`, the per-`<collision>` `geometry_subtree` roots, and the per-piece physics colliders are kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dismissing prior approval to re-evaluate bb83c08.
Problem
The URDF importer collapsed each link to a single geometry taken from its first
<visual>, andPhysicsWorldderived the collider — and, absent an authored<inertial>, the mass properties — from that one mesh. URDF links routinely declare several<collision>elements, a convex decomposition of the link, and those were ignored entirely.The pieces exist precisely because the single mesh is a bad collider. XLeRobot's
base_linkdeclares 3 visuals and 8 collisions; a cart body approximated by one mesh instead of its 8-piece decomposition gets the wrong contact surface.Design
IR —
PartDef::colliders: Option<Vec<NodeId>>, a list, not a union node. Unioning the pieces back together throws away the decomposition, andCsgOp::Unionis a real boolean, so it would weld the meshes into one shape and run the boolean pipeline to do it. A list maps 1:1 onto phyz'sBody::collisions: Vec<GeomInstance>, which already existed and whichphyz-env's contact pass already iterates. Absent means "the collider isroot" — what every non-URDF authoring path wants.Reader. One DAG root per
<collision>, sharing the primitive re-centering and<origin>placement with<visual>through a newgeometry_subtreehelper. Origins bake into the subtree, so no separate per-collider transform is needed in the IR.<visual>still drives the rendered root; collider roots are never scene entries. A link with no<visual>reuses its first collider root rather than duplicating the subtree.Physics.
from_document_with_colliderswas the natural seam —eval_instancenow returns aVec<Geometry>, each piece transformed into the body frame by the same map the visual mesh takes.Body::geometrymirrors the first piece so phyz's single-shape consumers (and the GPU contact pass, which only understands Sphere/Box/Capsule/Cylinder) keep working. Ground contact gathers candidates across all of a body's shapes before capping the manifold at 4 points, matching phyz's own semantics — a body resting on two of eight pieces keeps support under both instead of spending the whole budget on whichever piece was listed first.An unresolvable collider piece (a
package://mesh with no file behind it) is skipped rather than fatal; if none resolve, the part's own geometry stands in as before.Mass properties deliberately unchanged — still the part's own geometry or the authored
<inertial>. The decomposition describes the contact surface, not the material distribution, and its pieces routinely overlap, so summing their volumes would over-count. There's a test pinning this.validate_documentwalks the new collider refs too: nothing else points at those nodes, so a dangling one would otherwise surface as a physics-eval failure far from its cause.Tests
Against the vendored asset (
vcad-kernel-urdf/tests/xlerobot_collision_decomposition.rs):base_linkkeeps all 8<collision>pieces andMoving_Jawall 3 — distinct, resolvable, none doubling as the render root — and each resolves to its own_part*.plyconvex mesh rather than the.STLthe link renders as.Unit-level (
vcad-kernel-urdf): per-piece<origin>xyz+rpy placement; the render root is the visual, not a collision piece; no collider root leaks intoroots; no<collision>⇒ no colliders; collision-only link reuses piece 0 and contributes exactly one scene entry.Physics (
vcad-kernel-physics/tests/urdf_collision_decomposition.rs): two 50 mm pads separated by a 250 mm gap stay two shapes and neither bridges the gap (any collapse — first-visual, union, or hull — would); collider z-extent proves<collision>won over<visual>; mass still 4 kg from the visual slab, not 0.25 kg from the pads.Full workspace
cargo testgreen (0 failures),cargo clippyclean on the touched crates,cargo fmt --checkclean,npm run ir:checkup to date, TS build + tests green.Merge note
The XLeRobot work landed on
mainmid-flight and brought its own fix for the same quick-xml prerequisite — a link interleaving<visual>/<collision>(exactly what a decomposed link looks like) failed the whole parse withduplicate field "visual". Main'snormalize_link_child_order+group_children_by_tagsupersede this branch'sreorder_childrengeneralization; they're equivalent in effect and main's handles a self-closing<link .../>more precisely. See the merge commit and this comment.Known gaps, left untouched
<visual>[0]. XLeRobot'sbase_linkhas 3. Fixing it needs an IR grouping op —Unionis a boolean and would weld the meshes.<visual>, several<collision>pieces, and no<inertial>takes its mass from piece 0 alone. Pre-existing; summing overlapping pieces isn't obviously better, so it's documented in the code rather than changed.examples/xlerobot.vcadwas imported before this change and so carries nocolliders. It still loads and simulates exactly as it does onmain(absent ⇒ collider isroot); re-importing it would pick up the decomposition.🤖 Generated with Claude Code