Collision-primitive study — is the OBB the reason the arm reads as in-collision?
Status: analysis. The sphere-swept box it costs (§2–§7) never landed. Overtaken since: the manifest lowering now accepts several primitives per link and
lower_link_geometryno longer emits only a PCA capsule (#324, §3, §8.5); the staged hull narrow phase retired §5'spanda_link5↔panda_link7exemption (#191). Kernel and lowering changes are safety-WG gated and need a hazard-log entry. Companion: the validation evidence holds the failure census; this is the geometry half, reproducible by §9.
1. The premise this study was opened on is wrong, and that is the first result
The hypothesis was that the boxes are badly proud at their corners, the mobile
base worst of all. The geometry is right; the applicability to panda_mobile
is not: its collision model contains no base. robots/panda_mobile/robot.yaml
declares geometry for panda_link1 … panda_link7 and nothing else —
base_link excluded because base-vs-world is Nav2's 2-D costmap job,
panda_finger_pair because checking the intended-contact part against the world
would veto every grasp. The kernel never places a primitive on the chassis, so
a better chassis primitive buys exactly zero. Whatever makes kitchen layouts
read as in-collision is one of the seven arm links, or is not a link at all.
2. What the kernel supports today
2.1 The lowered model
CollisionModel (cpp/openral_safety_kernel/include/openral_safety_kernel/collision.hpp)
is two flat, parallel primitive arrays, each tagged with the link it rides on:
| array | element | fields |
|---|---|---|
capsule_link[c] / capsules[c] |
Capsule |
radius, half_length, origin |
box_link[b] / boxes[b] |
Obb |
half_extents, origin |
A sphere is a capsule with half_length == 0, which is how
envelope_loader.collision_params_from_description lowers SphereShape. No
mesh path — the kernel is analytic-convex-only so the hot path stays
allocation-free (since #166 a box may also carry a convex-hull refinement,
tight_geometry).
2.2 What the collision routines actually compute
| routine | box↔? | what it computes | exact? |
|---|---|---|---|
capsule_distance |
— | segment–segment distance − both radii | exact |
box_capsule_distance |
box↔capsule | 48-step ternary search of point→AABB distance along the capsule segment, in box-local coordinates, − capsule radius | exact when disjoint |
box_box_distance |
box↔box | separating-axis theorem over 15 axes; returns the largest per-axis gap | conservative lower bound |
check_voxel_collision |
box↔voxel | each occupied cell is an axis-aligned cube, then box_box_distance |
conservative |
fold_pair / finish_sweep (collision.cpp) are pure bookkeeping and
read no geometry, so neither is affected by a primitive change.
2.3 Would a radius on the box path be nearly free? Yes — verified, with one trap
Both distance routines offset exactly, one subtraction each. For convex sets
the Minkowski sum with a ball is an exact offset of the distance function, and
the support radius of Box(a) ⊕ B(r) along any unit n is
Σ_k a_k |â_k·n| + r — the + r is axis-independent, so the 15-axis SAT set
stays exactly as conservative as it is today.
The trap: the voxel broad phase. check_voxel_collision (collision.cpp)
computes the box's world AABB from half_extents alone —
const Vec3 ext = obb_extent(box_w, he); // Σ_j |R_kj|·he_j per grid axis
const double reach = margin + band_m + half_side;
— and only visits cells inside that window. With a = h − r the extents
under-report by exactly r, so cells that genuinely intersect the swept box are
never visited: not a conservatism loss but a missed collision, silently
returning clear. The fix is one term
(reach = margin + band_m + half_side + boxes[b].radius) and it is the single line in
the change that can make the kernel unsafe rather than merely tighter.
2.4 Every site that would move
struct Obb (collision.hpp) would gain double radius{0.0}; five sites in
collision.cpp would subtract it — check_self_collision box↔capsule and
box↔box, check_voxel_collision box↔voxel, check_attached_self_collision
payload↔link-box — and the voxel broad phase would add it to reach
(§2.3); lifecycle_kernel.cpp would declare and load a collision_box_radius
array. (check_world_collision, a sixth site when this was written, was retired
2026-09-23, ADR-0109.) Everything else — fold_pair, finish_sweep, forward_kinematics,
jacobian_dls_step, support_contact_exempts,
update_support_contact_witnesses, place_approach_allowance,
check_attached_voxel_collision — reads no model.boxes.
3. Multi-primitive links: the kernel already supports them; the manifest lowering rejects them
Since #324: the rejection is gone —
envelope_loader._primitives_by_linklowers every primitive of a link, andurdf_lowering.lower_link_geometryfits one capsule, one box or a capsule chain per link, or box + exact hull for a--tight-link(collision-geometry-review.md §0). §3.1–§3.3 record the state this study measured.
3.1 The rejection is real
$ PYTHONPATH=packages/openral_safety python -c "<duplicate panda_link1 in the manifest>"
pydantic entries: 8
LOWERING REJECTED: ROSConfigError link 'panda_link1' has >1 collision primitive;
split it into separate links (unsupported in this lowering phase)
Pydantic accepts it — RobotDescription.collision_geometry is a plain
list[LinkCollisionGeometry] with no uniqueness constraint on link_name, and
the eight-entry manifest validated, so no schema change is needed to express a
multi-primitive link. The refusal is envelope_loader._capsules_by_link,
which raises on the second entry for a link.
3.2 The kernel is fine with it — and a shipped test proves it
CollisionModel's docstring says a link may carry "zero, one, or several
capsules"; load_collision_model (lifecycle_kernel.cpp) only
range-checks the link index; check_self_collision skips same-link pairs
explicitly (li == lj, lb == lb2, lb == lc);
mjcf_lowering.lower_collision_params already emits several capsules per link;
and tests/sim/safety/test_kernel_h1_self_collision.py asserts
len(cap_links) > len(set(cap_links)), then starts a live kernel on it.
panda_mobile cannot use the path only because it comes through the manifest
path (URDF-lowered, hand-committed collision_geometry), not the MJCF path.
3.3 What lifting it costs
_capsules_by_link becomes _primitives_by_link returning
dict[str, list[LinkCollisionGeometry]] and the emit loop iterates it; the flat
arrays are already per-primitive with a link tag, so nothing downstream changes
shape. urdf_lowering.lower_link_geometry unions all of a link's
<collision> elements into one PCA capsule and
must stop unioning — the real work, but a generator change with no safety
surface of its own, since the safety property is whatever the emitted primitives
cover, checked at validation time. The ACM sweep keys on a single shape per link
(geoms[ln].shape) and would need to fold several; _collision_z_extent_m
already handles repeats.
No schema change. No kernel change. No new C++ — a materially smaller safety
surface than adding a radius to Obb, which is why §7 recommends it first.
4. The fit table
4.1 Method
Ground truth is robosuite's panda collision meshes — one link<N>_collision
mesh geom per link, the geoms the kernel's OBBs are documented as enclosing and
the set sim_sensor_bridge._body_collision_points samples. Each candidate is
fitted in the link frame and measured on max protrusion, the one-sided
Hausdorff distance from the primitive's boundary to the mesh surface (not a
proxy for the slop: it is the worst-case distance under-report, since the
kernel reports d(q, prim) where the truth is d(q, mesh)), and min
containment margin, the minimum inward depth of the mesh inside the primitive,
>= 0 being containment.
The margin is a proof, not a sample: a convex primitive containing every
mesh vertex contains conv(vertices) ⊇ mesh. The multi-primitive unions are not
convex, so whole triangles are assigned to one part each and that part's three
vertices checked. Nothing here was accepted on sampled evidence.
Cross-check: panda_link1's corner_slop_m is 53.4 mm in
collision-validation-evidence.md against
52.6 mm here; they should differ in exactly this direction, since corner_slop
measures to the nearest sampled mesh vertex, from 8 corners only.
4.2 The table
Protrusion in bold; the containment margin underneath it.
| candidate | link1 | link2 | link3 | link4 | link5 | link6 | link7 |
|---|---|---|---|---|---|---|---|
| OBB (shipped baseline) — protrusion (mm) | 52.6 | 45.2 | 74.0 | 76.3 | 43.7 | 49.6 | 27.3 |
| ↳ containment margin (mm) | 0.083 | 0.055 | 0.088 | 0.083 | 0.083 | 0.132 | 0.098 |
Sphere-swept box, faces fixed (a = h − r) — protrusion (mm) |
45.5 | 31.6 | 33.2 | 32.2 | 30.0 | 43.0 | 25.0 |
| ↳ containment margin (mm) | 0.021 | 0.040 | 0.012 | 0.072 | 0.067 | 0.132 | 0.037 |
| Sphere-swept box, free fit — protrusion (mm) | 44.8 | 25.4 | 21.0 | 19.4 | 27.3 | 36.0 | 17.0 |
| ↳ containment margin (mm) | 0.083 | 0.120 | 0.151 | 0.083 | 0.131 | 0.132 | 0.098 |
Capsule (PCA — what urdf_lowering emits) — protrusion (mm) |
111.7 | 107.5 | 99.7 | 99.6 | 104.1 | 96.2 | 54.5 |
| ↳ containment margin (mm) | 0.083 | 0.055 | 0.088 | 0.083 | 0.083 | 0.132 | 0.098 |
| 2 × OBB, slab split along the long axis — protrusion (mm) | 45.9 | 46.5 | 74.0 | 74.9 | 41.3 | 51.3 | 27.3 |
| ↳ containment margin (mm) | 0.083 | 0.083 | 0.083 | 0.083 | 0.083 | 0.083 | 0.083 |
| 3 × OBB, slab split along the long axis — protrusion (mm) | 52.0 | 54.6 | 74.4 | 76.5 | 49.7 | 51.7 | 27.8 |
| ↳ containment margin (mm) | 0.083 | 0.083 | 0.083 | 0.083 | 0.083 | 0.083 | 0.083 |
| 2 × OBB, k-means split — protrusion (mm) | 52.6 | 44.1 | 65.9 | 63.3 | 42.9 | 58.8 | 39.4 |
| ↳ containment margin (mm) | 0.083 | 0.055 | 0.088 | 0.083 | 0.083 | 0.132 | 0.098 |
| 2 × SSB, k-means split — protrusion (mm) | 43.5 | 37.3 | 36.2 | 37.6 | 32.6 | 53.3 | 27.3 |
| ↳ containment margin (mm) | 0.137 | 0.107 | 0.088 | 0.148 | 0.133 | 0.203 | 0.098 |
Per-link r for the swept candidates (mm): faces-fixed 23 / 19 / 39 / 41 / 15
/ 7 / 3; free fit 32.7 / 54.5 / 64.8 / 65.1 / 21.8 / 27.7 / 23.3.
Correction (picture book §4). The swept-box protrusion rows under-report: this study's boundary sampler under-covered the rounded corner shell. Re-measured exactly, the faces-fixed row is 46.4 / 34.8 / 48.0 / 46.4 / 34.1 / 47.3 / 26.2 mm, and a proven lower bound (36.4 / 32.8 / 47.4 / 46.1 / 33.7 / 47.1 / 26.0 mm) excludes six of the seven figures above. The free-fit and k-means SSB rows used the same sampler and were not re-measured. The OBB row is low by 0.4–3.2 mm (corners sampled, not evaluated). Containment margins and corner reach (§4.3, §6) are unaffected.
4.3 Reading it
The sphere-swept box works. Shrinking to a = h − r and sweeping r is not
automatically containing — a point near the box corner is up to √3·r from
Box(h − r) — but it holds up to a per-link maximum r of 3–41 mm, with
containment proven over the full vertex set. Protrusion falls 7 % (link7) to
40 % (link4) (re-measured, picture book §4), faces are untouched by construction, volume falls slightly.
A strict improvement: never more conservative anywhere than today, less
conservative only at the corners. The free fit reaches 17–21 mm on the mid-arm
links but grows faces by up to +11.7 mm on link2, so it is more
conservative than today on a face-on approach — the better number, the worse
guarantee, and §7 does not recommend it.
Corner reach is the quantity that moves, and by how much is what §6 needs:
| link | OBB corner reach (mm) | faces-fixed SSB | Δ | free-fit SSB | Δ | max face growth (free fit) |
|---|---|---|---|---|---|---|
| link1 | 167.8 | 154.9 | −12.9 | 150.8 | −17.0 | +3.0 |
| link2 | 167.4 | 156.6 | −10.8 | 149.0 | −18.4 | +11.7 |
| link3 | 157.5 | 134.6 | −22.9 | 130.1 | −27.4 | +7.2 |
| link4 | 158.7 | 134.8 | −23.9 | 131.1 | −27.6 | +5.8 |
| link5 | 191.4 | 183.9 | −7.5 | 182.5 | −8.9 | +1.6 |
| link6 | 125.1 | 120.4 | −4.7 | 112.0 | −13.1 | +5.9 |
| link7 | 88.7 | 86.8 | −1.9 | 79.4 | −9.3 | +6.7 |
The capsule is catastrophic here — 96–112 mm on every link but the wrist,
because the Panda's links are flanged, tapered blocks, not rods. This vindicates
the #103 conversion from capsules to boxes, and
urdf_lowering.lower_link_geometry then emitted a PCA capsule for any mesh
collision, so re-lowering panda_mobile from its URDF would have regressed the
envelope by 1.3–2.1× (§8.5 has what changed).
Multi-primitive decomposition does not help these links. Equal-mass slabs along the long axis and k-means on triangle centroids, at k = 2 and k = 3: neither beats a single sphere-swept box on any link, several are worse than the single OBB, and volume rises 1.0–1.8×. One column says why:
| link | link1 | link2 | link3 | link4 | link5 | link6 | link7 |
|---|---|---|---|---|---|---|---|
| convex-hull volume ÷ mesh volume | 1.303 | 1.000 | 1.0003 | 1.0001 | 1.000 | 1.0003 | 1.0002 |
Six of the seven collision meshes already are convex hulls — convex to
1e-4 in volume ratio, so on those links conv(mesh) is the mesh and there
is nothing to decompose. Decomposition removes concavity; the slop here is the
corner of a bounding box around a convex, rounded, tapered solid, and every
sub-box has corners of its own. Only panda_link1 is meaningfully non-convex
(30 % hull excess), and even there the 2-slab split (45.9 mm) ties the single
swept box (45.5 mm) at 1.1× the volume.
The shipped OBBs carry no padding to give back. Every containment margin above is 0.012–0.203 mm, so the slop is entirely the box's own corner geometry. A refit should declare an explicit headroom rather than inherit the current accidental one (§7.3).
5. Named finding: a box has no radius, and every scalar surrogate for one has now bitten us
Three places in this repo have substituted a single scalar radius for a box's three half-extents. Each picked a different surrogate, each was wrong in a different direction, none was conservative at both edges.
| site | surrogate | direction | consequence |
|---|---|---|---|
| the safety kernel's OBB, as an envelope for a rounded, tapered link | circumscribed, implicitly — a sharp box reaches \|h\| at its corners |
over-approximates | corners proud of the real link (§4); the kernel reads in-collision while the surface is clear |
~~urdf_lowering._capsule_segment_radius (offline ACM sweep)~~ — deleted, issue #155 fixed |
was inscribed, min(half_extents_m) |
under-approximated | The sweep now uses kernel_predicates.shape_distance — the kernel's own predicate on the true primitive. The remaining capsule lowering, bounding_capsule_segment, circumscribes and feeds only the cuMotion planner, where over-covering is the safe direction. See the note below: #155's conclusion was inverted, and the replacement numbers are grid-exhaustive rather than sampled. |
openral_slam_bringup.depth_height_filter_node._shape_z_span_m |
neither — it takes the exact OBB support projection Σ_k \|R[2][k]\|·h_k |
— | its docstring records why: "A box has no radius; the inscribed surrogate hides obstacles (shrinks the band), the circumscribed one drags the lower edge through the floor (grows it) — hence the exact span." |
The SLAM node got it right by refusing to pick a scalar at all:
A box's extent is direction-dependent. Any code that reduces it to one number is choosing a direction to be wrong in, and the two available choices are wrong in opposite directions. Use the support projection
Σ_k |û·ê_k|·h_k, or use the true box.
The middle row's original version inferred panda_link5↔panda_link7 was a
capsule-junction artifact the sweep ought to restore. Wrong: the pair
genuinely collides. Over (panda_joint6, panda_joint7), the only two joints
that move it, 967 of 14641 poses (6.60 %) interpenetrate (the #191
re-measurement on #169's grid, recorded in panda_mobile.srdf); MoveIt agrees
and emits no Never row. The committed ACM was exempting a real
self-collision, and the inscribed-sphere bug produced the correct ACM for the
wrong reason.
The box envelopes cannot express it either way: the box check fires on
86.38 % of that space (79.78 % false), including the arm's ready home
pose (box gap −9.12 mm), and no margin separates the populations — the
deepest real collision sits at a box gap of −8.37 mm, the shallowest false one at
−36.64 mm. It stayed exempt for that reason until #191 retired the row by
checking the pair on the exact hulls (§8.4). This is the strongest single
argument in this document for tighter link geometry, and it argues for a
change that stops substituting.
6. What it would actually recover — on the present evidence, nothing
6.1 The decision rule
A stop is recoverable only if the reach removed on the reporting link, in the direction of the reporting cell exceeds the penetration reported. Reach removal is maximal at the corner and zero on the faces, so §4.3's corner-reach reduction is a strict upper bound:
A stop reporting
min_distance = −drecovers only ifd < Δcorner_reach(reporting link).
Ceilings, faces-fixed variant: link1 12.9 · link2 10.8 · link3 22.9 · link4 23.9 · link5 7.5 · link6 4.7 · link7 1.9 mm. Doubly an upper bound: the cell is almost never at the exact corner, and the voxel half-diagonal (21.7 mm at the 25 mm sim grid) is untouched by any primitive change.
6.2 Applying it to the characterised stops
Applied to the link-class stops collision-validation-evidence.md has characterised (the layout census is produced separately; the rule is stated so it can be applied to that table when it lands):
| stop | link | kernel min_distance |
ceiling for that link | recovers? |
|---|---|---|---|---|
| baguette, 2026-08-22 | panda_link5 |
−20.9 mm | 7.5 mm | no — short by 13.4 mm |
| utensil, 2026-08-22 / -23 | panda_link1 |
−17.3 mm | 12.9 mm | no — short by 4.4 mm |
| harness-2 fridge | panda_link7 |
−24.7 mm | 1.9 mm | no — short by 22.8 mm (and already adjudicated real contact) |
None of the three recovers. Even the free-fit variant lifts the link5 ceiling only to 8.9 mm and the link1 ceiling to 17.0 mm. On every stop this repo has characterised, a tighter primitive does not clear the stop. §4 proves the envelope can be improved by 7–40 % (re-measured, picture book §4); §6 says that improvement recovers no layout we know about.
6.3 What that leaves
The baguette stop is the one the evidence corpus calls unexplained: 120.9 mm of discrepancy against an 88.2 mm admissible gap, with an untruncated ground-truth probe returning no pair at all within 100 mm. Removing 7.5 mm of link5 corner reach leaves ~113 mm unexplained; the envelope is not the mechanism.
The corpus's leading hypothesis is the one §1 sharpens: the robot's own base
entering the octomap as world occupancy. Excluding base_link from
collision_geometry is why nothing downstream knows the chassis is the robot,
and sim_sensor_bridge.voxel_backing_record already marks a cell
self_occupancy_suspect when "the robot's own body, base and mount included"
backs it. The utensil evidence voxel sits 195 mm from the link-1 origin at
mobile-base height. That is a map defect, not a primitive defect; one
voxel_backing_record call per characterised stop confirms or kills it, and
should be run before any geometry change is contemplated.
7. Cost, and what a safety-WG review would have to assert
7.1 Schema (§1.6)
Multi-primitive links: no schema change at all (§3.1).
Sphere-swept box: additive, but not silently. BoxShape carries
extra="forbid", so adding corner_radius_m: float = Field(default=0.0, ge=0.0)
is backward-compatible (every existing manifest loads unchanged; 0.0
reproduces today's box bit for bit through every distance routine, §2.3 — no
migrator) but not forward-compatible: a manifest that uses the field fails
to load on an older reader, which is fail-closed but still a real break for
anyone pinning an older openral-core against a newer robots/ tree. Under
§1.6 it may evolve in place provided the release notes state that coupling; a
RoundedBoxShape union member has the same property and buys nothing. Either
way docs/methods/00-core-schemas.md and docs/methods/06-… need updating.
7.2 Kernel and lowering
| change | files | size | safety surface |
|---|---|---|---|
| multi-primitive lowering | envelope_loader._capsules_by_link + emit loop; urdf_lowering.lower_link_geometry |
~60 lines Python | none new — the kernel path is already exercised by test_kernel_h1_self_collision |
Obb::radius |
collision.hpp, 6 call sites in collision.cpp, 3 in lifecycle_kernel.cpp (§2.4) |
~25 lines C++ | one line that can make the kernel unsafe — the voxel broad-phase reach (§2.3) |
| downstream box readers | depth_height_filter_node._shape_z_span_m; tools/viz_collision.py |
~10 lines | the SLAM height band must add the radius or the band under-covers (_capsule_segment_radius is gone — #155) |
| per-robot refits | every box-bearing manifest (three when this was written; since #324 most arm manifests ship boxes + hulls) | data | each needs its own containment proof re-run |
7.3 What the hazard entry and the safety-WG review would have to assert
- Containment, per link, as a proof and not a sample — every vertex of the link's collision mesh inside the refitted primitive, achieved margin reported (method in §4).
- An explicitly declared headroom. The shipped margins are 0.012–0.203 mm (§4.3) — accidental, not designed. A refit must state the headroom it chose (1 mm is a defensible default) and the achieved margin against it.
radius = 0is bit-identical to today — a regression test, not a claim.- The voxel broad phase was widened, by name, with a test that fails without it: a cell intersecting the swept box but outside the shrunken box's AABB must still be visited. It is the only way the change can miss a collision rather than merely tighten one.
- The SAT bound is unchanged in character —
box_box_distancestays a conservative lower bound after the offset (§2.3). - ~~
_capsule_segment_radiusis fixed first, or the ACM is not regenerated.~~ Discharged — #155 is fixed and a regeneration reproduces every shipped ACM byte-identically (all 10 manifests). - The recovery claim is the one in §6, not a larger one. Justifying a less conservative envelope on a benefit the evidence does not show is the exact failure mode §1.2 exists to prevent.
7.4 Hazard-log Entry 012 lockstep — not touched
Entry 012 obliges support_contact_exempts and the bridge's
payload_clearing.support_patch_withholds to move together so withheld ⊆
exempt stays true by construction. support_contact_exempts reads no
model.boxes, and payload_clearing.cpp's surface_distance /
bounding_radius switch on the attached payload's
PayloadPrimitive::shape_type — so a robot-link Obb radius engages neither.
Conditional on scope: extended to AttachedPrimitive (a rounded payload
box) it would touch both, since bounding_radius would under-report the AABB
span exactly as the voxel broad phase does (§2.3). Do not extend it to
payloads. One in-scope interaction still belongs in the hazard entry:
check_attached_self_collision compares payload primitives against robot link
boxes, so a link-box radius makes that check less
conservative too, bounded by the same per-link Δ.
8. Recommendation
8.1 Do not do the primitive change yet — and possibly not at all. §6
governs: the tightening is provably containing and no characterised stop
recovers, and a less conservative safety envelope for a benefit the evidence
does not demonstrate is not a trade this repo should make (§1.1, §1.2). First
run voxel_backing_record and settle the self-occupancy hypothesis (§6.3), then
apply §6.1's rule to the layout census when it lands. If the census shows stops
reporting shallower than their link's ceiling — say under 10 mm on link1/2/3/4 —
the calculus changes and §8.2 becomes worth doing.
8.2 If it is done, do the faces-fixed sphere-swept box, not the free fit.
a = h − r with a per-link r is a strict improvement whose safety argument
fits in a paragraph. The free fit is 4–13 mm tighter at the corner but grows
faces by up to 11.7 mm, making it a different envelope rather than a smaller
one.
8.3 Do not pursue multi-primitive links for this problem. It is the cheaper
change with the smaller safety surface, and it does not work: six of seven
collision meshes are already convex hulls (§4.3), and every split tried was
equal to or worse than a single swept box at 1.0–1.8× the volume. Lifting the
_capsules_by_link restriction is still worthwhile as removing an asymmetry
between the two lowering paths, but not as a fix for envelope slop.
8.4 ~~Fix issue #155 regardless.~~ Done, and it strengthened this document's
recommendation: panda_link5↔panda_link7 is a real self-collision the box
envelopes cannot distinguish from an artifact at any margin.
Retired by issue #191, and
this section called it correctly. Tighter geometry was the only thing that
could — but not the tighter box §8.2 proposes: the corner-reach reduction
available there is 7.5 mm on link5 and 1.9 mm on link7 (§4.3's table) against
the ~28 mm the separation needs. What retired it is the exact hull, via the
staged narrow phase extended from world voxels
to self-pairs, and §4.3's convexity column is why it works: link5 and link7 are
convex to 1e-4 in volume ratio, so conv(mesh) is the mesh and the hull's
verdict is the mesh's. Over the pair's full (joint6, joint7) grid the
false-positive population goes from 11 680 of 14 641 poses to zero.
8.5 Fix urdf_lowering.lower_link_geometry's mesh path regardless. It
emitted a PCA capsule for a mesh collision, which §4.2 measures at 96–112 mm of
protrusion against the shipped boxes' 27–76 mm. Re-lowering panda_mobile from
its URDF today would inflate the envelope slop by up to 2.1× and silently undo
the #103 conversion. Whether or not §8.2 lands, a mesh should lower to a box.
Update 2026-09-24 (audit F9).
fit_capsule_to_verticesno longer spans the full axial projection: the segment is now the shortest one on the PCA axis whose capsule still holds every vertex, so the caps stop overhanging the mesh by up to a radius. That removed the Franka's self-collision at its SRDFreadypose and H1's at q = 0, and shrinks the §4.2 protrusion, but the mesh path still emits a capsule, and re-loweringpanda_mobilestill loosens every arm link (1.18–2.08× by volume,test_collision_geometry_no_loosening.py). §8.5 stands.Update 2026-09-24 (PR #324 review). The capsule fit is now minimum-volume (axis search, minimal-enclosing-circle line, 1 mm declared headroom) and holds the link's
<collision>and<visual>geometry — the vendor collision blocks under-covered H1 by up to 549 mm, G1 by 61 mm, SO-100 by 15 mm. Apanda_mobilere-lower would now loosen five of seven links (up to 1.31× by volume), not seven. The review also measures why a capsule of any count per link cannot clear the humanoid rest poses and recommends §8.5's direction — box + exact hull — for every mesh-backed link: collision-geometry-review.md.This is not hypothetical, and #191 hit it.
render_cumotion_configwas sourcing its collision spheres fromLoweredCollisionModel.collision_geometry— i.e. from exactly this PCA-capsule path — rather than from the manifest the kernel checks, so "plan-time and kernel-time share one source of truth" was untrue for every hand-authored box manifest. It now prefers the manifest.Since #324 landed:
lower_link_geometrykeeps whichever of one capsule, one box or a capsule chain has the least mean protrusion, and lowers a--tight-linkas box + exact hull (collision-geometry-review.md §0, §3).test_collision_geometry_no_loosening.pystill guards a re-lower against loosening a shipped envelope.
9. Reproducing the numbers
The scripts are not checked in (one-shot analysis; §1.11 keeps fixtures for tests rather than studies), but are reconstructible from this section.
Inputs. robosuite 1.5.2's models/assets/robots/panda/robot.xml under
mujoco 3.8.0; the collision geoms are the link<N>_collision mesh geoms
(contype/conaffinity non-zero — exactly one per link body, link1…link7).
Manifest values from robots/panda_mobile/robot.yaml. Vertices come from
model.mesh_vert placed by each geom's geom_pos/geom_quat, matching
sim_sensor_bridge._body_collision_points exactly.
Signed distances. Box: ‖max(|p| − a, 0)‖ + min(max_k(|p_k| − a_k), 0).
Sphere-swept box: the same, minus r. Capsule: ‖p − clamp_z(p)‖ − r.
Protrusion. The primitive's boundary is sampled (12 000 points; flat faces
directly, the rounded shell as box-surface points pushed along random outward
directions and filtered to |dist(·, Box(a)) − r| < 1e−9), then
trimesh.proximity.closest_point against the collision mesh; maximum reported.
SSB fitting. Faces-fixed: a = h − r, r swept at 1 mm (0.1 mm for §4.3's
headroom check), largest containing r taken. Free fit: SLSQP minimising
Σ a_k subject to max_p dist(p, Box(a)) ≤ r − headroom, then a monotone
inflation loop until containment holds exactly — an optimiser's convergence
tolerance is not a safety argument. Multi-primitive: triangles (not
vertices) are assigned to one part, by equal-mass slabs along the link's longest
shipped half-extent and by kmeans2 on triangle centroids; each part's three
vertices are checked against that part alone.
Environment: workspace .venv (numpy 2.2.6, scipy 1.17.1, trimesh 4.12.2,
mujoco 3.8.0, robosuite 1.5.2). CPU only.