Skip to content

Box-box collider returns a phantom deep contact (dist = -box_width) for boxes resting face-to-face at near-zero penetration #3476

Description

@kmoy007

Intro

Hi!

I am an engineer at Leap Automation, where we use MuJoCo to build digital twins of vision-guided pick-and-place machines for food packing.

My setup

MuJoCo 3.10.0 and 3.11.0 (both reproduce), Python bindings, macOS arm64 (Apple Silicon). Default solver options; margin/gap are 0. Reproduces with and without nativeccd and multiccd (box-box pairs take the analytic primitive collider in all combinations).

What's happening? What did you expect?

The box-box collider returns a phantom deep contact for two identical boxes resting face-to-face at near-zero penetration: alongside the correct contact (dist = -0.056 mm at a corner), the manifold contains a second point with dist = -71.056 — the full box width along the flush axis plus the real depth — with the same +y normal. The true configuration is two boxes whose faces kiss with ~0.06 mm overlap at one corner; a 71 mm overlap along the contact normal is geometrically impossible for these poses.

Consequences in a full simulation: the constraint solver resolves the phantom penetration by ejecting the two resting bodies at ±17.9 m/s in a single 0.5 ms step. In our digital twin this detonated a settled 3×3 stack of packed products inside a tray (products at rest, zero velocity, zero applied force for seconds beforehand) — the stacked rows had drifted into flush face-to-face contact, which is a resident configuration for packed goods.

The degenerate branch appears to be a point singularity: perturbing either box by ±10 µm in x, y, or z, or ±0.1° of yaw — in any direction — yields correct contacts (either none, or shallow ~-0.06 depths). Deeper real penetration (-0.01 mm and beyond) is also handled correctly. Resting stacks still find the branch because a settled body's qpos is frozen (qvel exactly zero) while its quaternion creeps at ~1e-13/substep, sliding the pair along the singular manifold until a step lands on it; in our sim the same pair hit it three times in 7 seconds.

The phantom depth is exactly real_depth - 2*size[1] (the flush axis' full extent), which smells like the face-clipping code measuring depth against the wrong (far) face for one manifold point when the boxes are near-identical in pose (relative yaw here is ~0.5°, bottom faces coplanar, side edges flush to within 0.07 mm).

I'm aware from #1800 / #2816 that a replacement box-box collider is written and pending; per the request in that thread, offering this as an additional regression case — it's a fabricated deep contact rather than a missed one, so it may exercise different code than the failure-to-detect cases.

Steps for reproduction

Run the script below (poses are 17-significant-digit reprs, which round-trip float64 exactly, captured from the live sim at the detonating step). Expected output on 3.10.0/3.11.0:

contact depths (mm): [-71.056, -0.056]

Expected correct behavior: only shallow contacts (~-0.056 at the touching corner).

Minimal model for reproduction

import mujoco

# Model is in mm/g units (box 150 x 71 x 45 mm); the phantom is
# unit-independent — depths below are in model units.
XML = """
<mujoco>
  <option timestep="0.0005"/>
  <worldbody>
    <body name="A"><freejoint/>
      <geom name="gA" type="box" size="75 35.5 22.5" mass="0.25"/>
    </body>
    <body name="B"><freejoint/>
      <geom name="gB" type="box" size="75 35.5 22.5" mass="0.25"/>
    </body>
  </worldbody>
</mujoco>
"""

# Both boxes yawed ~90 deg (relative yaw ~0.5 deg), faces kissing
# with a real ~0.056 corner overlap; bottom faces coplanar.
POSE_A = [862.4672361088551, 444.5974448882937, -87.50001574938747,
          0.70870472649706, 2.2005940043163968e-13,
          -1.8342950101702237e-13, 0.7055052165935622]
POSE_B = [862.4009654665109, 594.9219226097865, -87.50002095390447,
          0.7054546906050059, -7.947440490083151e-08,
          -3.301086291725321e-08, 0.7087550207958939]

model = mujoco.MjModel.from_xml_string(XML)
data = mujoco.MjData(model)
data.qpos[0:7] = POSE_A
data.qpos[7:14] = POSE_B
mujoco.mj_forward(model, data)

print("mujoco", mujoco.__version__)
print("contact depths (mm):",
      [round(float(data.contact[i].dist), 3) for i in range(data.ncon)])
# 3.10.0 / 3.11.0: [-71.056, -0.056]  <- -71.056 is the phantom
# (= full 71 box width + the real 0.056 corner depth, normal +y).
# Perturb either pose by 1e-2 (10 um) on any coordinate, or add
# 0.1 deg of yaw, and the phantom disappears.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions