diff --git a/docs/source/api/lab/isaaclab.scene.rst b/docs/source/api/lab/isaaclab.scene.rst index 86866dfdadb3..f12121460fd9 100644 --- a/docs/source/api/lab/isaaclab.scene.rst +++ b/docs/source/api/lab/isaaclab.scene.rst @@ -9,8 +9,9 @@ InteractiveScene InteractiveSceneCfg + CollisionGroupCfg -interactive Scene +Interactive Scene ----------------- .. autoclass:: InteractiveScene @@ -21,3 +22,7 @@ interactive Scene .. autoclass:: InteractiveSceneCfg :members: :exclude-members: __init__ + +.. autoclass:: CollisionGroupCfg + :members: + :exclude-members: __init__ diff --git a/docs/source/tutorials/02_scene/create_scene.rst b/docs/source/tutorials/02_scene/create_scene.rst index a2d34cf57e78..6d8a7891fb69 100644 --- a/docs/source/tutorials/02_scene/create_scene.rst +++ b/docs/source/tutorials/02_scene/create_scene.rst @@ -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. diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 52f92be2364e..24d3151cda98 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -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" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 5269d6946b01..ce3affe6896d 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -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` + using outdated ``name`` keyword argument (renamed to ``prim_name`` in recent Isaac Sim versions). + + 4.5.25 (2026-04-01) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/scene/__init__.pyi b/source/isaaclab/isaaclab/scene/__init__.pyi index 9a6543cc28ec..8762045af34c 100644 --- a/source/isaaclab/isaaclab/scene/__init__.pyi +++ b/source/isaaclab/isaaclab/scene/__init__.pyi @@ -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 diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 306c0dd390b2..c47d9597d184 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -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 @@ -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: + self.filter_collisions(self._global_prim_paths) def clone_environments(self, copy_from_source: bool = False): """Creates clones of the environment ``/World/envs/env_0``. @@ -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)})." + ) diff --git a/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py b/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py index f4328324152c..bf6d35f753db 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene_cfg.py @@ -8,6 +8,56 @@ from isaaclab.utils.configclass import configclass +@configclass +class CollisionGroupCfg: + """Configuration for a named collision group within each environment. + + This allows specifying which assets belong to a collision group and which + other groups this group is allowed to collide with (allowlist semantics). + + Example: + .. code-block:: python + + collision_groups = { + "robot": CollisionGroupCfg( + assets=["robot_arm", "robot_hand"], + collides_with=["obstacles"], + ), + "obstacles": CollisionGroupCfg( + assets=["table", "wall"], + collides_with=["robot"], + ), + "phantom": CollisionGroupCfg( + assets=["sensor_body"], + collides_with=[], # collides with nothing + ), + } + """ + + assets: list[str] = MISSING + """List of scene entity names that belong to this collision group. + + Each name must correspond to an attribute name in the :class:`InteractiveSceneCfg`. + """ + + collides_with: list[str] | None = None + """List of collision group names that this group is allowed to collide with. + + * ``None`` (default): This group collides with all other defined collision groups. + * ``[]`` (empty list): This group collides with nothing (fully isolated). + * ``["group_a", "group_b"]``: This group collides only with the listed groups. + + Collision between two groups requires **mutual agreement**: groups A and B + collide only if A accepts B (A lists B, or A uses ``None``) **and** B accepts A + (B lists A, or B uses ``None``). This means ``collides_with=[]`` is always + respected — no other group can force collisions onto an isolated group. + + .. note:: + Each group always collides with itself (assets within the same group + can collide with each other). + """ + + @configclass class InteractiveSceneCfg: """Configuration for the interactive scene. @@ -111,6 +161,47 @@ class MySceneCfg(InteractiveSceneCfg): ``scene.filter_collisions()``. """ + collision_groups: dict[str, CollisionGroupCfg] | None = None + """Optional dictionary of named collision groups for intra-environment collision filtering. + + Keys are group names, values are :class:`CollisionGroupCfg` instances defining which assets + belong to each group and which groups are allowed to collide with each other. + + When set, the scene creates USD ``PhysicsCollisionGroup`` prims that control which assets + within the same environment can collide. This is orthogonal to :attr:`filter_collisions`, + which controls *inter*-environment collision isolation. + + Assets not assigned to any collision group follow default physics behavior and collide + with everything in their environment. + + If ``None`` (default), no intra-environment collision filtering is applied. + + Example: + + .. code-block:: python + + @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=[], + ), + } + + 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", ...) + """ + clone_in_fabric: bool = False """Enable/disable cloning in fabric. Default is False. diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 087474baa59e..9bc5e898d37d 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -18,10 +18,12 @@ import torch import warp as wp +from pxr import PhysxSchema + import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg, RigidObjectCfg -from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.scene import CollisionGroupCfg, InteractiveScene, InteractiveSceneCfg from isaaclab.sim import build_simulation_context from isaaclab.utils import configclass from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR @@ -199,6 +201,181 @@ def assert_state_equal(s1: dict, s2: dict, path=""): pytest.fail(f"Tensor mismatch at {subpath}, max abs diff = {diff}") +@configclass +class CollisionGroupSceneCfg(InteractiveSceneCfg): + """Scene config for collision group tests.""" + + cube_a = RigidObjectCfg( + prim_path="/World/envs/env_.*/CubeA", + spawn=sim_utils.CuboidCfg( + size=(0.5, 0.5, 0.5), + rigid_props=sim_utils.RigidBodyPropertiesCfg(disable_gravity=True), + collision_props=sim_utils.CollisionPropertiesCfg(collision_enabled=True), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 0.5)), + ) + cube_b = RigidObjectCfg( + prim_path="/World/envs/env_.*/CubeB", + spawn=sim_utils.CuboidCfg( + size=(0.5, 0.5, 0.5), + rigid_props=sim_utils.RigidBodyPropertiesCfg(disable_gravity=True), + collision_props=sim_utils.CollisionPropertiesCfg(collision_enabled=True), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(1.0, 0.0, 0.5)), + ) + cube_c = RigidObjectCfg( + prim_path="/World/envs/env_.*/CubeC", + spawn=sim_utils.CuboidCfg( + size=(0.5, 0.5, 0.5), + rigid_props=sim_utils.RigidBodyPropertiesCfg(disable_gravity=True), + collision_props=sim_utils.CollisionPropertiesCfg(collision_enabled=True), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(2.0, 0.0, 0.5)), + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_collision_groups_prim_creation(device): + """Verify collision group USD prims are created with correct structure.""" + with build_simulation_context(device=device, add_ground_plane=False) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = CollisionGroupSceneCfg(num_envs=2, env_spacing=2.0) + scene_cfg.collision_groups = { + "group_ab": CollisionGroupCfg(assets=["cube_a", "cube_b"], collides_with=["group_c"]), + "group_c": CollisionGroupCfg(assets=["cube_c"], collides_with=["group_ab"]), + } + scene = InteractiveScene(scene_cfg) + + # check scope prim exists + scope_prim = scene.stage.GetPrimAtPath("/World/collisions") + assert scope_prim.IsValid() + + # check each env/group combo + for env_idx in range(2): + for group_name in ["group_ab", "group_c"]: + prim_path = f"/World/collisions/env{env_idx}_{group_name}" + prim = scene.stage.GetPrimAtPath(prim_path) + assert prim.IsValid(), f"Missing prim: {prim_path}" + assert prim.GetPrimTypeInfo().GetTypeName() == "PhysicsCollisionGroup" + + # check expansion rule + assert prim.GetAttribute("collection:colliders:expansionRule").Get() == "expandPrims" + + # check includes relationship has targets + includes = prim.GetRelationship("collection:colliders:includes").GetTargets() + assert len(includes) > 0 + + # check filteredGroups relationship has targets + filtered = prim.GetRelationship("physics:filteredGroups").GetTargets() + assert len(filtered) > 0 + + # check includes targets point to correct assets + prim_ab_env0 = scene.stage.GetPrimAtPath("/World/collisions/env0_group_ab") + includes_ab = [str(t) for t in prim_ab_env0.GetRelationship("collection:colliders:includes").GetTargets()] + assert "/World/envs/env_0/CubeA" in includes_ab + assert "/World/envs/env_0/CubeB" in includes_ab + + prim_c_env0 = scene.stage.GetPrimAtPath("/World/collisions/env0_group_c") + includes_c = [str(t) for t in prim_c_env0.GetRelationship("collection:colliders:includes").GetTargets()] + assert "/World/envs/env_0/CubeC" in includes_c + + # check InvertCollisionGroupFilterAttr is set + physx_scene = PhysxSchema.PhysxSceneAPI(scene.stage.GetPrimAtPath(scene.physics_scene_path)) + assert physx_scene.GetInvertCollisionGroupFilterAttr().Get() is True + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_collision_groups_mutual_agreement(device): + """Verify that collisions require mutual agreement between groups. + + group_a wants to collide with group_b, but group_b says collides_with=[]. + Since both sides must agree, they should NOT collide. + """ + with build_simulation_context(device=device, add_ground_plane=False) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = CollisionGroupSceneCfg(num_envs=1, env_spacing=2.0) + scene_cfg.collision_groups = { + "group_a": CollisionGroupCfg(assets=["cube_a"], collides_with=["group_b"]), + "group_b": CollisionGroupCfg(assets=["cube_b"], collides_with=[]), + } + scene = InteractiveScene(scene_cfg) + + # group_a should only have self (group_b rejected) + prim_a = scene.stage.GetPrimAtPath("/World/collisions/env0_group_a") + filtered_a = [str(t) for t in prim_a.GetRelationship("physics:filteredGroups").GetTargets()] + assert "/World/collisions/env0_group_a" in filtered_a + assert "/World/collisions/env0_group_b" not in filtered_a + + # group_b should only have self + prim_b = scene.stage.GetPrimAtPath("/World/collisions/env0_group_b") + filtered_b = [str(t) for t in prim_b.GetRelationship("physics:filteredGroups").GetTargets()] + assert "/World/collisions/env0_group_b" in filtered_b + assert "/World/collisions/env0_group_a" not in filtered_b + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_collision_groups_collides_with_none(device): + """Verify collides_with=None means willing to collide with all, but requires mutual agreement.""" + with build_simulation_context(device=device, add_ground_plane=False) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = CollisionGroupSceneCfg(num_envs=1, env_spacing=2.0) + scene_cfg.collision_groups = { + "group_a": CollisionGroupCfg(assets=["cube_a"], collides_with=None), # willing to collide with all + "group_b": CollisionGroupCfg(assets=["cube_b"], collides_with=[]), # isolated + "group_c": CollisionGroupCfg(assets=["cube_c"], collides_with=None), # willing to collide with all + } + scene = InteractiveScene(scene_cfg) + + # group_a (None) + group_c (None) → both agree → collide + prim_a = scene.stage.GetPrimAtPath("/World/collisions/env0_group_a") + filtered_a = [str(t) for t in prim_a.GetRelationship("physics:filteredGroups").GetTargets()] + assert "/World/collisions/env0_group_a" in filtered_a + assert "/World/collisions/env0_group_c" in filtered_a + + # group_a (None) + group_b ([]) → group_b rejects → no collision + assert "/World/collisions/env0_group_b" not in filtered_a + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_collision_groups_invalid_asset_name(device): + """Verify ValueError when collision group references nonexistent asset.""" + with build_simulation_context(device=device, add_ground_plane=False) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = CollisionGroupSceneCfg(num_envs=1, env_spacing=2.0) + scene_cfg.collision_groups = { + "group_a": CollisionGroupCfg(assets=["nonexistent_asset"], collides_with=[]), + } + with pytest.raises(ValueError, match="nonexistent_asset"): + InteractiveScene(scene_cfg) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_collision_groups_invalid_group_reference(device): + """Verify ValueError when collides_with references undefined group.""" + with build_simulation_context(device=device, add_ground_plane=False) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = CollisionGroupSceneCfg(num_envs=1, env_spacing=2.0) + scene_cfg.collision_groups = { + "group_a": CollisionGroupCfg(assets=["cube_a"], collides_with=["nonexistent_group"]), + } + with pytest.raises(ValueError, match="nonexistent_group"): + InteractiveScene(scene_cfg) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_collision_groups_none_preserves_existing_behavior(device): + """Verify that collision_groups=None (default) doesn't create per-env collision group prims.""" + with build_simulation_context(device=device, add_ground_plane=False) as sim: + sim._app_control_on_stop_handle = None + scene_cfg = CollisionGroupSceneCfg(num_envs=2, env_spacing=2.0) + # collision_groups defaults to None + scene = InteractiveScene(scene_cfg) + + # no per-env collision group prims should exist + env0_group_prim = scene.stage.GetPrimAtPath("/World/collisions/env0_group_ab") + assert not env0_group_prim.IsValid() + + def assert_state_different(s1: dict, s2: dict, path=""): """ Recursively scan s1 and s2 (which must have identical keys) and