Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/source/api/lab/isaaclab.scene.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@

InteractiveScene
InteractiveSceneCfg
CollisionGroupCfg

interactive Scene
Interactive Scene
-----------------

.. autoclass:: InteractiveScene
Expand All @@ -21,3 +22,7 @@ interactive Scene
.. autoclass:: InteractiveSceneCfg
:members:
:exclude-members: __init__

.. autoclass:: CollisionGroupCfg
:members:
:exclude-members: __init__
54 changes: 54 additions & 0 deletions docs/source/tutorials/02_scene/create_scene.rst
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,60 @@ In this tutorial, we saw how to use :class:`scene.InteractiveScene` to create a
scene with multiple assets. We also saw how to use the ``num_envs`` argument
to clone the scene for multiple environments.

Collision Groups
----------------

For scenes with multiple assets that should not all collide with each other, you can use
:class:`scene.CollisionGroupCfg` to define intra-environment collision filtering. This lets you
control exactly which assets can collide within the same environment.

For example, suppose you have a robot arm, some obstacles, and a sensor body that should not
physically interact with anything:

.. code-block:: python

from isaaclab.scene import CollisionGroupCfg, InteractiveSceneCfg

@configclass
class MySceneCfg(InteractiveSceneCfg):
collision_groups = {
"robot": CollisionGroupCfg(
assets=["robot_arm"],
collides_with=["obstacles"],
),
"obstacles": CollisionGroupCfg(
assets=["table"],
collides_with=["robot"],
),
"phantom": CollisionGroupCfg(
assets=["sensor_body"],
collides_with=[], # collides with nothing
),
}

robot_arm = FRANKA_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot")
table = RigidObjectCfg(prim_path="{ENV_REGEX_NS}/Table", ...)
sensor_body = RigidObjectCfg(prim_path="{ENV_REGEX_NS}/Sensor", ...)

Key points:

* Each group lists the scene entity names it contains via ``assets``.
* ``collides_with`` controls which other groups this group can collide with:

* ``None`` (default) — collides with all other groups.
* ``[]`` (empty list) — collides with nothing (fully isolated).
* ``["group_a", "group_b"]`` — collides only with the listed groups.

* Collision between two groups requires **mutual agreement**: groups A and B collide only if
A accepts B **and** B accepts A. This means ``collides_with=[]`` is always respected.
* Each group always collides with itself (assets within the same group can collide).
* Assets not assigned to any group follow default physics behavior.
* When ``collision_groups`` is set, inter-environment isolation is handled automatically
(replacing :attr:`~scene.InteractiveSceneCfg.filter_collisions`).

For the full API reference, see :class:`scene.CollisionGroupCfg` and
:attr:`scene.InteractiveSceneCfg.collision_groups`.

There are many more example usages of the :class:`scene.InteractiveSceneCfg` in the tasks found
under the ``isaaclab_tasks`` extension. Please check out the source code to see
how they are used for more complex scenes.
2 changes: 1 addition & 1 deletion source/isaaclab/config/extension.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]

# Note: Semantic Versioning is used: https://semver.org/
version = "4.5.25"
version = "4.5.26"

# Description
title = "Isaac Lab framework for Robot Learning"
Expand Down
17 changes: 17 additions & 0 deletions source/isaaclab/docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
Changelog
---------

4.5.26 (2026-04-03)
~~~~~~~~~~~~~~~~~~~

Added
^^^^^

* Added :class:`~isaaclab.scene.CollisionGroupCfg` and :attr:`~isaaclab.scene.InteractiveSceneCfg.collision_groups`
for intra-environment collision filtering. This allows fine-grained control over which assets can
collide within each environment using named collision groups with allowlist semantics.

Fixed
^^^^^

* Fixed ``CreateShaderPrimFromSdrCommand`` call in :func:`~isaaclab.sim.spawners.materials.visual_materials.spawn_preview_surface`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning: This fix is not in this PR

The visual_materials.py file is not modified in this PR. Either:

  1. Remove this changelog entry, or
  2. Include the actual fix in this PR
Suggested change
* Fixed ``CreateShaderPrimFromSdrCommand`` call in :func:`~isaaclab.sim.spawners.materials.visual_materials.spawn_preview_surface`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bot is correct!

using outdated ``name`` keyword argument (renamed to ``prim_name`` in recent Isaac Sim versions).


4.5.25 (2026-04-01)
~~~~~~~~~~~~~~~~~~~

Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/isaaclab/scene/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
# SPDX-License-Identifier: BSD-3-Clause

__all__ = [
"CollisionGroupCfg",
"InteractiveScene",
"InteractiveSceneCfg",
]

from .interactive_scene import InteractiveScene
from .interactive_scene_cfg import InteractiveSceneCfg
from .interactive_scene_cfg import CollisionGroupCfg, InteractiveSceneCfg
176 changes: 173 additions & 3 deletions source/isaaclab/isaaclab/scene/interactive_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import torch
import warp as wp

from pxr import Sdf
from pxr import PhysxSchema, Sdf, Usd, UsdGeom

import isaaclab.sim as sim_utils
from isaaclab import cloner
Expand Down Expand Up @@ -206,8 +206,16 @@ def __init__(self, cfg: InteractiveSceneCfg):
if has_scene_cfg_entities:
self.clone_environments(copy_from_source=(not self.cfg.replicate_physics))
# Collision filtering is PhysX-specific (PhysxSchema.PhysxSceneAPI)
if self.cfg.filter_collisions and "physx" in self.physics_backend:
self.filter_collisions(self._global_prim_paths)
if "physx" in self.physics_backend:
# When collision_groups is configured, _apply_collision_groups handles both inter-env
# isolation and intra-env filtering in a single unified set of collision groups.
# Using filter_collisions alongside collision_groups would create conflicting groups
# (the cloner's per-env group allows all intra-env collisions, overriding the
# finer-grained intra-env restrictions).
if self.cfg.collision_groups:
self._apply_collision_groups()
elif self.cfg.filter_collisions:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical: Unassigned assets break inter-env isolation

When collision_groups is set, filter_collisions is skipped. But assets NOT assigned to any collision group won't be in any PhysicsCollisionGroup prim. With InvertCollisionGroupFilter=True, these unassigned prims will collide with everything — including assets in other environments.

Example: Scene with robot, table, sensor where only robot and table are in collision groups. The unassigned sensor collides across all envs.

Suggested fix: After Step 2 validation, check for unassigned assets and either:

  1. Auto-create a "default" group for them
  2. Raise ValueError listing unassigned assets
  3. At minimum, log a warning
Suggested change
elif self.cfg.filter_collisions:
if self.cfg.collision_groups:
self._apply_collision_groups()
# TODO: Warn or handle assets not assigned to any collision group
elif self.cfg.filter_collisions:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreeing with the bot here. At the very least a warning should be thrown.

self.filter_collisions(self._global_prim_paths)
Comment on lines +215 to +218
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 filter_collisions=True silently ignored with no warning

When collision_groups is set, the default value of filter_collisions=True is silently swallowed by the elif. Since users almost never explicitly set filter_collisions=False, every user who enables collision_groups will hit this branch without knowing it. If filter_collisions is True and collision_groups is also set, a warning should be emitted so the behaviour is transparent:

if self.cfg.collision_groups:
    if self.cfg.filter_collisions:
        logger.warning(
            "Both 'collision_groups' and 'filter_collisions=True' are set. "
            "'filter_collisions' is ignored when 'collision_groups' is configured — "
            "inter-environment isolation is handled by _apply_collision_groups instead."
        )
    self._apply_collision_groups()
elif self.cfg.filter_collisions:
    self.filter_collisions(self._global_prim_paths)

Without this warning, users who rely on the default filter_collisions=True will get no indication that that flag has no effect.


def clone_environments(self, copy_from_source: bool = False):
"""Creates clones of the environment ``/World/envs/env_0``.
Expand Down Expand Up @@ -859,3 +867,165 @@ def _resolve_sensor_template_spawn_path(self, template_base: str, proto_id: str)
)
found = sim_utils.find_matching_prim_paths(search)
return f"{found[0]}/{leaf}" if found else f"{template_base}/{proto_id}_.*"

def _apply_collision_groups(self):
"""Create USD PhysicsCollisionGroup prims for unified collision filtering.

This method replaces the cloner's ``filter_collisions`` when :attr:`InteractiveSceneCfg.collision_groups`
is configured. It creates a single, unified set of collision groups that handles both:

- **Inter-environment isolation**: each env's groups only reference same-env groups in
``filteredGroups``, so prims in different environments never collide.
- **Intra-environment filtering**: within each environment, only groups listed in each
other's ``filteredGroups`` can collide (allowlist semantics).
- **Global prim collisions**: a dedicated global group (for ground plane, etc.) is created
and wired so all env groups can collide with it.

Raises:
ValueError: If an asset name in a collision group does not exist in the scene config.
ValueError: If a group name in ``collides_with`` does not exist in ``collision_groups``.
"""
collision_groups_cfg = self.cfg.collision_groups
if not collision_groups_cfg:
return

# -- Step 1: Collect all scene entity names and their prim paths
entity_prim_paths: dict[str, str] = {}
for asset_name, asset_cfg in self.cfg.__dict__.items():
if asset_name in InteractiveSceneCfg.__dataclass_fields__ or asset_cfg is None:
continue
if hasattr(asset_cfg, "prim_path"):
entity_prim_paths[asset_name] = asset_cfg.prim_path

# -- Step 2: Validate config
group_names = list(collision_groups_cfg.keys())
for group_name, group_cfg in collision_groups_cfg.items():
# validate asset references
for asset_name in group_cfg.assets:
if asset_name not in entity_prim_paths:
available = list(entity_prim_paths.keys())
raise ValueError(
f"Collision group '{group_name}' references asset '{asset_name}' which does not"
f" exist in the scene config. Available entities: {available}"
)
# validate collides_with references
if group_cfg.collides_with is not None:
for ref_group in group_cfg.collides_with:
if ref_group not in collision_groups_cfg:
raise ValueError(
f"Collision group '{group_name}' lists '{ref_group}' in collides_with,"
f" but no such group is defined. Available groups: {group_names}"
)

# -- Step 3: Build collision matrix
# Two groups collide only when BOTH sides agree. A pair (A, B) collides iff:
# - A lists B (or A uses None = "all") AND B lists A (or B uses None = "all")
# This means collides_with=[] is always respected — no other group can force
# collisions onto an isolated group.
group_collides: dict[str, set[str]] = {}
for group_name in group_names:
group_collides[group_name] = set()

for i, name_a in enumerate(group_names):
cfg_a = collision_groups_cfg[name_a]
for name_b in group_names[i:]:
cfg_b = collision_groups_cfg[name_b]
# check if A accepts B
a_accepts_b = cfg_a.collides_with is None or name_b in cfg_a.collides_with or name_a == name_b
# check if B accepts A
b_accepts_a = cfg_b.collides_with is None or name_a in cfg_b.collides_with or name_a == name_b
if a_accepts_b and b_accepts_a:
group_collides[name_a].add(name_b)
group_collides[name_b].add(name_a)

# -- Step 4: Resolve prim paths per group for env_0
group_env0_paths: dict[str, list[str]] = {name: [] for name in group_names}
for group_name, group_cfg in collision_groups_cfg.items():
for asset_name in group_cfg.assets:
prim_path = entity_prim_paths[asset_name]
# resolve regex to env_0 path
env0_path = prim_path.replace(self.env_regex_ns, self.env_prim_paths[0])
group_env0_paths[group_name].append(env0_path)

# -- Step 5: Ensure InvertCollisionGroupFilterAttr is set
physx_scene = PhysxSchema.PhysxSceneAPI(self.stage.GetPrimAtPath(self.physics_scene_path))
invert_attr = physx_scene.GetInvertCollisionGroupFilterAttr()
if not invert_attr or not invert_attr.Get():
physx_scene.CreateInvertCollisionGroupFilterAttr().Set(True)

# -- Step 6: Create USD collision group prims
collision_root = "/World/collisions"
has_global_paths = len(self._global_prim_paths) > 0
global_group_path = f"{collision_root}/global_group"

# create the scope prim in the root layer
with Usd.EditContext(self.stage, Usd.EditTarget(self.stage.GetRootLayer())):
UsdGeom.Scope.Define(self.stage, collision_root)

with Sdf.ChangeBlock():
root_spec = self.stage.GetRootLayer().GetPrimAtPath(collision_root)

# -- create global group for ground plane and other global prims
if has_global_paths:
global_group = Sdf.PrimSpec(root_spec, "global_group", Sdf.SpecifierDef, "PhysicsCollisionGroup")
global_group.SetInfo(Usd.Tokens.apiSchemas, Sdf.TokenListOp.Create({"CollectionAPI:colliders"}))

expansion_rule = Sdf.AttributeSpec(
global_group,
"collection:colliders:expansionRule",
Sdf.ValueTypeNames.Token,
Sdf.VariabilityUniform,
)
expansion_rule.default = "expandPrims"

global_includes = Sdf.RelationshipSpec(global_group, "collection:colliders:includes", False)
for gpath in self._global_prim_paths:
global_includes.targetPathList.Append(gpath)

# filteredGroups for global group — will be populated below with all env groups
global_filtered = Sdf.RelationshipSpec(global_group, "physics:filteredGroups", False)
# global group collides with itself (e.g. multiple global prims can collide)
global_filtered.targetPathList.Append(global_group_path)

# -- create per-env collision groups
for env_idx, env_prim_path in enumerate(self.env_prim_paths):
for group_name in group_names:
prim_name = f"env{env_idx}_{group_name}"

# create PhysicsCollisionGroup prim
collision_group = Sdf.PrimSpec(root_spec, prim_name, Sdf.SpecifierDef, "PhysicsCollisionGroup")
collision_group.SetInfo(Usd.Tokens.apiSchemas, Sdf.TokenListOp.Create({"CollectionAPI:colliders"}))

# expansion rule
expansion_rule = Sdf.AttributeSpec(
collision_group,
"collection:colliders:expansionRule",
Sdf.ValueTypeNames.Token,
Sdf.VariabilityUniform,
)
expansion_rule.default = "expandPrims"

# includes relationship — asset prim paths for this env
includes_rel = Sdf.RelationshipSpec(collision_group, "collection:colliders:includes", False)
for env0_asset_path in group_env0_paths[group_name]:
# replace env_0 path with this env's path
env_asset_path = env0_asset_path.replace(self.env_prim_paths[0], env_prim_path)
includes_rel.targetPathList.Append(env_asset_path)

# filteredGroups — same-env groups only (provides inter-env isolation)
filtered_groups = Sdf.RelationshipSpec(collision_group, "physics:filteredGroups", False)
for collide_group_name in group_collides[group_name]:
collide_prim_path = f"{collision_root}/env{env_idx}_{collide_group_name}"
filtered_groups.targetPathList.Append(collide_prim_path)

# allow collision with global group (ground plane, etc.)
if has_global_paths:
filtered_groups.targetPathList.Append(global_group_path)
# also let global group collide with this env group
global_filtered.targetPathList.Append(f"{collision_root}/{prim_name}")

logger.info(
f"Created collision groups at '{collision_root}'"
f" with {len(group_names)} groups across {len(self.env_prim_paths)} environments"
f" (global paths: {len(self._global_prim_paths)})."
)
Comment on lines +871 to +1031
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this belongs here. Could we add it to the IsaacLab utilities?

Loading
Loading