[Bug]: MJCF contype/conaffinity are rewritten whenever the file declares <contact><exclude>
Bug Description
An MJCF states forbidden collision pairs two ways, and MuJoCo keeps them apart: the per-geom contype / conaffinity masks, and <contact><exclude> (compiled into exclude_signature). Genesis's MJCF importer folds both into the masks: whenever the file declares at least one <exclude>, it gathers every forbidden pair and asks solve_contype_conaffinity (z3) for a fresh bitmask assignment realizing all of them at once, overwriting the values the file wrote (genesis/utils/mjcf.py, the if mj.nexclude: block in parse_geoms).
The synthesized assignment is only meaningful inside the entity it was solved over — nothing in the problem mentions the ground or any other entity — so the masks it hands out bear no relation to theirs. One <exclude> anywhere in the file is enough to rewrite the masks of every geom in it, including geoms unrelated to the exclusion.
This is also why _is_local_collision_mask currently has to cover MJCF (rigid_entity.py), which means an MJCF's contype / conaffinity never filter cross-entity pairs at all — an MJCF that uses masks to keep a geom off the floor cannot be reproduced in Genesis today.
Steps to Reproduce
import genesis as gs
XML = """
<mujoco model="exclude_probe">
<compiler angle="radian" autolimits="true"/>
<worldbody>
<body name="carrier" pos="0 0 0">
<freejoint name="carrier_free"/>
<geom name="carrier_geom" type="sphere" size="0.05" pos="0 0 1" contype="1" conaffinity="1"/>
<body name="lander" pos="0 -0.3 0.05">
<joint name="lander_hinge" type="hinge" axis="0 0 1" range="0 0"/>
<geom name="lander_geom" type="sphere" size="0.1" contype="1" conaffinity="1"/>
</body>
<body name="floater" pos="0 0.3 0.05">
<joint name="floater_hinge" type="hinge" axis="0 0 1" range="0 0"/>
<geom name="floater_geom" type="sphere" size="0.1" contype="2" conaffinity="2"/>
</body>
<body name="armA" pos="0.5 0 1">
<joint name="armA_hinge" type="hinge" axis="0 0 1" range="0 0"/>
<geom name="armA_geom" type="sphere" size="0.1" contype="1" conaffinity="1"/>
</body>
<body name="armB" pos="0.62 0 1">
<joint name="armB_hinge" type="hinge" axis="0 0 1" range="0 0"/>
<geom name="armB_geom" type="sphere" size="0.1" contype="1" conaffinity="1"/>
</body>
<body name="armC" pos="0.38 0 1">
<joint name="armC_hinge" type="hinge" axis="0 0 1" range="0 0"/>
<geom name="armC_geom" type="sphere" size="0.1" contype="1" conaffinity="1"/>
</body>
</body>
</worldbody>
<contact>
<exclude body1="armA" body2="armB"/>
</contact>
</mujoco>
"""
gs.init(backend=gs.cpu, logging_level="warning")
scene = gs.Scene(
sim_options=gs.options.SimOptions(gravity=(0.0, 0.0, 0.0)),
show_viewer=False,
)
scene.add_entity(gs.morphs.Plane(contype=1, conaffinity=1))
robot = scene.add_entity(gs.morphs.MJCF(file=XML))
scene.build()
for link in robot.links:
for geom in link.geoms:
print(f"{link.name:12s} contype {geom.contype} conaffinity {geom.conaffinity}")Both spheres near the ground are sunk 50 mm into a contype=1 conaffinity=1 plane, so floater (authored 2/2, i.e. (2&1)|(1&2) == 0) should produce no ground contact while lander (1/1) should — stepping the scene and reading scene.rigid_solver.collider.get_contacts() shows both touching.
Expected Behavior
The masks survive import as written (this is what MuJoCo compiles):
lander contype 1 conaffinity 1
floater contype 2 conaffinity 2
armA contype 1 conaffinity 1
armB contype 1 conaffinity 1
armC contype 1 conaffinity 1and floater produces no contact against a 1/1 ground.
Relevant log output
Actual output for the probe links (masks rewritten by the entity-local z3 solve; the exclusion
names only armA/armB, yet every geom's masks changed):
lander contype 2 conaffinity 2
floater contype 0 conaffinity 1
armA contype 0 conaffinity 2
armB contype 0 conaffinity 2
armC contype 2 conaffinity 2
Removing the <exclude> element makes all masks import verbatim, confirming the trigger.Environment
- OS: macOS 15 (Apple M4 Pro, CPU backend) — also reproduced on Ubuntu 22.04 (CUDA); backend-independent, the rewrite happens at parse time
- GPU/CPU: Apple M4 Pro / NVIDIA RTX
- GPU-driver version: N/A
- CUDA / CUDA-toolkit version: N/A
Release version or Commit ID
Reproduced on main (70a0f413) and on release 1.3.3.
Additional Context
The masks and the exclusions are independent mechanisms in MuJoCo; folding the exclusions into the masks is what forces the entity-local solve and, downstream, the is_local_collision_mask guard that disables MJCF masks across entities. Carrying <exclude> as an explicit link-pair list (the way welded pairs are already filtered in _compute_collision_pair_idx) removes both. I have a patch with a regression test ready and will open a PR referencing this issue.
Source: Genesis-Embodied-AI/genesis-world