Skip to content

Commit 40d4d6f

Browse files
Fix CCD narrowphase reporting margin-biased contact distances (#1188)
The CCD (GJK/EPA) path inflates each geom by 0.5 * pair_margin in the support function, but the returned dist was never corrected back to the true surface-to-surface distance. This caused dist to be off by pair_margin compared to the primitive narrowphase, breaking the constraint pipeline which expects un-inflated distances. Add `dist += margin` after the early-exit check in eval_ccd_write_contact to restore consistency with the primitive path.
1 parent 458634d commit 40d4d6f

2 files changed

Lines changed: 61 additions & 0 deletions

File tree

mujoco_warp/_src/collision_convex.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,13 @@ def eval_ccd_write_contact(
789789
if dist >= 0.0 and pairid[1] == -1:
790790
return 0
791791

792+
# CCD operates on margin-inflated shapes (support() inflates each geom by
793+
# 0.5 * margin). The returned dist is therefore relative to the inflated
794+
# geometry. Correct back to the true surface-to-surface distance so that
795+
# the constraint pipeline (pos = dist - includemargin) works consistently
796+
# with the primitive narrowphase, which reports un-inflated distances.
797+
dist += margin
798+
792799
witness1[0] = w1
793800
witness2[0] = w2
794801

mujoco_warp/_src/collision_driver_test.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,60 @@ def test_sdf_volume_collision(self, fixture):
11141114
test_dist = d.contact.dist.numpy()[i]
11151115
self.assertLess(test_dist, 0.1, f"Contact {i} dist={test_dist} not indicating penetration")
11161116

1117+
def test_ccd_margin_dist(self):
1118+
"""Tests that CCD contact dist matches MuJoCo when margin > 0.
1119+
1120+
Two boxes are placed 0.05 m apart (not touching). With margin=0.1 on
1121+
each geom the pair margin is 0.2, so contacts are detected within the
1122+
speculative envelope. The reported dist must equal the true geometric
1123+
separation (≈0.05), not the margin-biased value that the inflated
1124+
GJK/EPA would produce.
1125+
"""
1126+
xml = """
1127+
<mujoco>
1128+
<worldbody>
1129+
<body pos="0 0 0">
1130+
<freejoint/>
1131+
<geom type="box" size="0.15 0.15 0.25" margin="0.1" gap="0.1"/>
1132+
</body>
1133+
<body pos="0 0 0.35">
1134+
<freejoint/>
1135+
<geom type="box" size="0.1 0.1 0.05" margin="0.1" gap="0.1"/>
1136+
</body>
1137+
</worldbody>
1138+
</mujoco>
1139+
"""
1140+
mjm, mjd, m, d = test_data.fixture(xml=xml)
1141+
1142+
mujoco.mj_forward(mjm, mjd)
1143+
mjw.forward(m, d)
1144+
1145+
mj_ncon = mjd.ncon
1146+
mjw_ncon = d.nacon.numpy()[0]
1147+
1148+
self.assertGreater(mj_ncon, 0, "Classic MuJoCo should detect speculative contacts")
1149+
self.assertGreater(mjw_ncon, 0, "MuJoCo Warp should detect speculative contacts")
1150+
1151+
# All classic MuJoCo contacts should have positive dist (separated)
1152+
for i in range(mj_ncon):
1153+
self.assertGreater(mjd.contact.dist[i], 0.0)
1154+
1155+
# Check that mujoco-warp dist matches classic MuJoCo dist
1156+
for i in range(mj_ncon):
1157+
mj_dist = mjd.contact.dist[i]
1158+
# Find the matching contact in mujoco-warp
1159+
found = False
1160+
for j in range(mjw_ncon):
1161+
mjw_dist = d.contact.dist.numpy()[j]
1162+
if np.allclose(mj_dist, mjw_dist, atol=1e-2, rtol=5e-2):
1163+
found = True
1164+
break
1165+
self.assertTrue(found, f"MJ contact {i} dist={mj_dist:.4f} not matched in MJW")
1166+
1167+
# Verify no constraint forces are generated (includemargin=0, dist > 0)
1168+
self.assertEqual(mjd.nefc, 0, "Classic MuJoCo should have no active constraints")
1169+
self.assertEqual(d.nefc.numpy()[0], 0, "MuJoCo Warp should have no active constraints")
1170+
11171171

11181172
if __name__ == "__main__":
11191173
absltest.main()

0 commit comments

Comments
 (0)