Skip to content

Commit e925834

Browse files
authored
Merge branch 'main' into mym/vbd-proxy-full-surface-contact
2 parents 87b6513 + 4d64341 commit e925834

10 files changed

Lines changed: 1888 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
- Fix USD capsule, cylinder, and cone visuals and sites without authored `radius`/`height` to use the UsdGeom schema fallbacks, matching collision shapes.
117117
- Fix `ViewerUSD` texture consumers observing partially written PNGs by publishing generated textures atomically (#3288)
118118
- Fix loading of textures packaged inside `.usdz` archives; package-relative asset paths such as `scene.usdz[tex.png]` are resolved through USD's asset resolver instead of being treated as filesystem paths.
119+
- Preserve cross-import collision pairs when `SolverMuJoCo` combines independently imported MJCF mask domains.
119120
- Fix `ModelBuilder.add_usd()` raising `ValueError` when importing a mesh whose material subset binds a texture that decodes to an image array.
120121
- Fix `ModelBuilder.add_usd()` dropping textures from full meshes and material subsets without recoverable UVs; preserve the texture for projected rendering.
121122
- Fix textured USD visual meshes and material subsets rendering tinted by scalar or default per-shape colors; textured meshes now import with a white base color so their textures are shown untinted.
@@ -128,6 +129,7 @@
128129
- Fix `ModelBuilder.add_usd()` treating explicitly authored USD `MassAPI` schema fallback values (zero mass, density, inertia, or principal axes; non-finite center of mass) as overrides; per the schema's value semantics they now behave like unauthored attributes, while negative or non-finite mass, density, and diagonal inertia values are ignored with a warning. (#3418)
129130
- Report malformed MJCF free-joint and inertial inputs with deterministic validation errors, and ignore MJCF mesh geom `size` lengths consistently.
130131
- Fix MJCF imports ignoring material and inline RGBA colors on primitive geoms.
132+
- Preserve MJCF `contype`/`conaffinity` collision filtering when importing into Newton, and compose later Newton pair filters when using native MuJoCo contacts.
131133
- Fix `SolverMuJoCo` site poses for offset batched worlds and site poses and sizes for runtime shape updates. (#3389)
132134
- Fix `ModelBuilder.add_usd()` silently dropping a MuJoCo joint equality constraint when the asset supplies the leader joint and coefficients through `NewtonMimicAPI` instead of the deprecated `mjc:target`, `mjc:coef0`, and `mjc:coef1`. `MjcEqualityJointAPI` builds on `NewtonMimicAPI`, so both spellings are now accepted.
133135
- Fix `ModelBuilder.add_usd()` ignoring `newton:mimicEnabled` on a joint with `MjcEqualityJointAPI` applied. Such a joint is now imported disabled rather than coupled, which also stops the default equality conversion from enforcing the coupling in every solver.

docs/solvers/mujoco.rst

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,80 @@ Newton's pipeline supports non-convex meshes, SDF-based contacts, and
467467
hydroelastic contacts, which are not available through MuJoCo's collision
468468
detection.
469469

470+
Collision filtering
471+
~~~~~~~~~~~~~~~~~~~
472+
473+
MuJoCo gives every geom two 32-bit masks, ``contype`` and ``conaffinity``.
474+
Together, these masks decide whether two geoms are allowed to collide. For a
475+
candidate pair ``a, b``, the mask test passes when
476+
``(contype_a & conaffinity_b) != 0`` or
477+
``(contype_b & conaffinity_a) != 0``. MuJoCo then applies other selection
478+
rules, including same-body suppression and body-wide ``<exclude>`` elements;
479+
explicit ``<pair>`` elements bypass the automatic mask test. See MuJoCo's
480+
`collision selection documentation
481+
<https://mujoco.readthedocs.io/en/stable/computation/index.html#selection>`__
482+
and the `geom mask attributes
483+
<https://mujoco.readthedocs.io/en/stable/XMLreference.html#body-geom>`__.
484+
485+
**Importing MJCF masks.**
486+
487+
:func:`~newton.utils.parse_mjcf` resolves inherited ``contype`` and
488+
``conaffinity`` values and determines which shape pairs may collide. It stores
489+
the same result in Newton collision groups and explicit excluded pairs. The
490+
original 32-bit values are also retained as
491+
``model.mujoco.contype`` and ``model.mujoco.conaffinity`` custom attributes
492+
for a lossless round trip back to MuJoCo.
493+
494+
A *mask domain* is the set of shapes whose mask bits were authored together.
495+
Each :func:`~newton.utils.parse_mjcf` call creates a new domain and records it
496+
in the internal ``model.mujoco.collision_mask_domain`` attribute. The domain
497+
is only a source label. It is not another collision mask and does not enable
498+
or disable contacts. When :meth:`~newton.ModelBuilder.add_builder` combines
499+
separately built models, it gives the copied domains new IDs so they remain
500+
distinct from domains already in the destination builder.
501+
502+
**Choosing masks for a MuJoCo solver.**
503+
504+
With ``use_mujoco_contacts=True``, preserved source masks are forwarded
505+
verbatim only when every selected collision shape has masks from the same
506+
domain and those masks already enforce all active Newton pair filters.
507+
Same-body filtering and imported body-wide ``<exclude>`` elements also count
508+
as enforced. This path preserves the source MJCF exactly. For a single import,
509+
the original masks therefore remain the source of truth even if its Newton
510+
collision groups are later edited.
511+
512+
A native shape, shapes from more than one domain, or a new Newton pair filter
513+
can make the original masks unsafe to reuse. The solver then lists the shape
514+
pairs that Newton allows and creates new MuJoCo masks that reproduce that
515+
list.
516+
517+
**Example: combining two MJCF files.**
518+
519+
Suppose file A and file B both use bit 0. In file A, bit 0 may control contacts
520+
between its floor and spheres. File B may reuse bit 0 for its own shapes. The
521+
files were authored independently, so that shared number says nothing about
522+
how a shape from A should interact with a shape from B.
523+
524+
After both files are added to one builder, Newton's collision groups and
525+
excluded pairs define those new cross-file interactions. Copying the original
526+
masks would make MuJoCo treat bit 0 as one global rule and could allow or block
527+
the wrong cross-file pairs. Because the shapes have different domains, the
528+
solver instead creates new masks from Newton's final list of allowed pairs.
529+
530+
**Compiling Newton filtering to MuJoCo masks.**
531+
532+
MuJoCo provides only 32 mask bits. One bit can encode all collisions between
533+
one set of shapes and another set. In graph terminology, that rule is a
534+
complete bipartite graph, or biclique. The solver tries to reproduce Newton's
535+
full list of allowed pairs using at most 32 such rules. It is guaranteed to
536+
find an exact result for up to 33 selected shapes and often handles much larger
537+
models whose collision groups have a regular structure.
538+
539+
If the rules do not fit in 32 bits, the solver uses the established legacy
540+
graph-color approximation, which may allow extra contacts. Finding an exact
541+
result requires checking every shape pair, so models above 256 selected shapes
542+
or 1,024 explicit excluded pairs skip directly to that fallback.
543+
470544
.. _mujoco-margin-gap-mapping:
471545

472546
Margin and gap mapping
@@ -782,16 +856,10 @@ Caveats
782856
prescribed. The user-supplied armature on those DOFs is silently
783857
discarded. See `Kinematic links and fixed roots`_.
784858

785-
**Collision filtering bitmask fallback.**
786-
Newton's :attr:`~newton.Model.shape_collision_group` (see
787-
:ref:`Collision Groups`) is translated to MuJoCo's ``contype`` /
788-
``conaffinity`` via graph coloring
789-
(:github:`newton/_src/sim/graph_coloring.py`). Up to 32 colors are
790-
supported (one per ``contype`` bit). If the filtering graph requires
791-
more, shapes with color index ≥ 32 fall back to ``contype=1`` /
792-
``conaffinity=1`` and silently collide with every other shape,
793-
bypassing the intended filtering and adding extra contact pairs to
794-
the broadphase.
859+
**Collision filtering has a 32-bit capacity.**
860+
The solver creates MuJoCo masks that reproduce Newton's allowed collision
861+
pairs when they fit in 32 bits. See `Collision filtering`_ for the behavior
862+
of imported masks and the warned fallback used when the rules do not fit.
795863

796864

797865
.. _mujoco-kinematic-links-and-fixed-roots:

newton/_src/sim/builder.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3961,6 +3961,29 @@ def _merge_builder_custom_attributes(
39613961
) -> None:
39623962
custom_frequency_offsets = dict(self._custom_frequency_counts)
39633963

3964+
# Builders allocate MJCF mask-domain IDs independently. Remap every
3965+
# incoming domain as one unit so its IDs cannot collide with domains
3966+
# already present in the destination builder.
3967+
collision_mask_domain_key = "mujoco:collision_mask_domain"
3968+
collision_mask_domain_remap: dict[int, int] = {}
3969+
source_domain_attr = builder.custom_attributes.get(collision_mask_domain_key)
3970+
if source_domain_attr is not None and source_domain_attr.values:
3971+
source_items = (
3972+
source_domain_attr.values.items()
3973+
if isinstance(source_domain_attr.values, dict)
3974+
else enumerate(source_domain_attr.values)
3975+
)
3976+
# Copied shape ranges never overlap, so the first destination shape
3977+
# in each source domain is already a unique, deterministic ID. This
3978+
# avoids rescanning the growing destination during replication.
3979+
shape_offset = entity_offsets["shape"]
3980+
for shape, value in source_items:
3981+
if value is None:
3982+
continue
3983+
source_domain = int(value)
3984+
if source_domain >= 0:
3985+
collision_mask_domain_remap.setdefault(source_domain, shape_offset + shape)
3986+
39643987
def get_offset(entity_or_key: str | None) -> int:
39653988
if entity_or_key is None:
39663989
return 0
@@ -3997,7 +4020,10 @@ def get_offset(entity_or_key: str | None) -> int:
39974020
use_current_world = attr.references == "world"
39984021
value_offset = 0 if use_current_world else get_offset(attr.references)
39994022
is_equality_target_attr = full_key == "mujoco:equality_constraint_target"
4000-
needs_remap = value_offset != 0 or use_current_world or is_equality_target_attr
4023+
is_collision_mask_domain_attr = full_key == collision_mask_domain_key and bool(collision_mask_domain_remap)
4024+
needs_remap = (
4025+
value_offset != 0 or use_current_world or is_equality_target_attr or is_collision_mask_domain_attr
4026+
)
40014027

40024028
if needs_remap:
40034029

@@ -4053,9 +4079,12 @@ def transform_enum_value(
40534079
entity_idx: int,
40544080
value: Any,
40554081
is_equality_target: bool = is_equality_target_attr,
4082+
is_collision_mask_domain: bool = is_collision_mask_domain_attr,
40564083
) -> Any:
40574084
if is_equality_target:
40584085
return transform_equality_target_value(entity_idx, value)
4086+
if is_collision_mask_domain:
4087+
return collision_mask_domain_remap.get(int(value), value)
40594088
return transform_value(value)
40604089

40614090
merged = self.custom_attributes.get(full_key)
@@ -12438,34 +12467,36 @@ def _find_shape_contact_pairs(self, model: Model, *, allow_filter_blocks: bool)
1243812467
self._iter_validated_shape_collision_filter_pairs((*filter_pairs.explicit_pairs, *floating_block_pairs))
1243912468
)
1244012469

12441-
# Builder-side compact blocks are valid only while they describe the
12442-
# model's filters exactly; otherwise the general path queries the model.
12443-
use_filter_blocks = bool(world_filter_blocks) and allow_filter_blocks
12444-
if use_filter_blocks:
12470+
# Builder-side storage is valid only while it describes the model's
12471+
# filters exactly; otherwise the general path queries the model.
12472+
use_world_templates = (
12473+
allow_filter_blocks and self.world_count > 0 and isinstance(filter_pairs, _BuilderShapeCollisionFilterPairs)
12474+
)
12475+
if use_world_templates:
1244512476
shape_world_np = np.asarray(self.shape_world, dtype=np.int32)
1244612477
starts = self.shape_world_start
1244712478
if len(starts) != self.world_count + 2:
12448-
use_filter_blocks = False
12479+
use_world_templates = False
1244912480
else:
1245012481
segment_worlds = np.full(self.shape_count, -1, dtype=np.int32)
1245112482
for world in range(self.world_count):
1245212483
segment_worlds[starts[world] : starts[world + 1]] = world
12453-
use_filter_blocks = np.array_equal(segment_worlds, shape_world_np)
12484+
use_world_templates = np.array_equal(segment_worlds, shape_world_np)
1245412485

12455-
if use_filter_blocks:
12486+
if use_world_templates:
1245612487
blocks_by_world = {}
1245712488
global_filter_pairs = set()
1245812489
explicit_filters_by_world = {}
1245912490
for block in world_filter_blocks:
1246012491
world = block.world
1246112492
if world < 0 or world >= self.world_count:
12462-
use_filter_blocks = False
12493+
use_world_templates = False
1246312494
break
1246412495

1246512496
world_start = self.shape_world_start[world]
1246612497
world_end = self.shape_world_start[world + 1]
1246712498
if block.shape_start < world_start or block.shape_start + block.shape_count > world_end:
12468-
use_filter_blocks = False
12499+
use_world_templates = False
1246912500
break
1247012501

1247112502
# Store block starts as world-local offsets for the template cache
@@ -12474,7 +12505,7 @@ def _find_shape_contact_pairs(self, model: Model, *, allow_filter_blocks: bool)
1247412505
(block.shape_start - world_start, block.shape_count, block.local_pairs)
1247512506
)
1247612507

12477-
if use_filter_blocks:
12508+
if use_world_templates:
1247812509
# Residual explicit filters may involve global shapes, so split
1247912510
# them into globally keyed filters and per-world local filters.
1248012511
for shape_a, shape_b in explicit_filter_pairs:
@@ -12498,7 +12529,7 @@ def _find_shape_contact_pairs(self, model: Model, *, allow_filter_blocks: bool)
1249812529
)
1249912530
# Cross-world pairs never collide, so filtering them is a no-op.
1250012531

12501-
if use_filter_blocks:
12532+
if use_world_templates:
1250212533
contact_pairs = []
1250312534
shape_flags_np = np.asarray(self.shape_flags, dtype=np.int64)
1250412535
colliding_np = (shape_flags_np & int(ShapeFlags.COLLIDE_SHAPES)) != 0

0 commit comments

Comments
 (0)