[Bug]: MJCF contype/conaffinity are rewritten whenever the file declares <contact><exclude>

Author: jsw7460Created Aug 25, 2026Updated Sep 7, 2026
LabelsenhancementP1

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

python
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 1

and 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