diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index ce9ac079e480..2311ca1269ba 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -130,6 +130,8 @@ this page: - Long tensor of target env ids. * - ``positions`` - Optional per-env world positions [m], shape ``[num_envs, 3]``. + * - ``global_paths`` + - Unique prim paths for scene assets shared by every env and therefore not replicated. The plan is stage-agnostic by design — the same instance can be replayed against a different stage, inspected by tooling, or serialized. @@ -141,6 +143,7 @@ When every env is a copy of env_0: sources = ("/World/envs/env_0",) destinations = ("/World/envs/env_{}",) clone_mask = [[True, True, ..., True]] + global_paths = ("/World/Ground", "/World/Light") When envs differ — say a cartpole in every env plus a 2-variant obstacle (box into envs 0/1, sphere into envs 2/3): @@ -215,8 +218,7 @@ and exiting the block drains every registration against the plan: .. code-block:: python - with cloner.ReplicateSession(cfgs, num_clones=N, env_spacing=2.0, - device=device, stage=stage): + with cloner.ReplicateSession(cfgs, num_clones=N, env_spacing=2.0, device=device, stage=stage): for cfg in cfgs: cfg.class_type(cfg) @@ -264,7 +266,7 @@ Shortcut for the case where every env is just a copy of env_0. one line by pointing at the prototype, and :func:`~isaaclab.cloner.replicate` finishes the setup. This is the pattern most :class:`~isaaclab.envs.DirectRLEnv` subclasses use — they author the env-0 prototype prim by prim in -``_setup_scene`` and end the method with these four lines: +``_setup_scene`` and end the method with this sequence: .. code-block:: python @@ -275,11 +277,13 @@ subclasses use — they author the env-0 prototype prim by prim in src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) Every env receives the same prototype. When envs need to differ, use one of the -other two. +other two. Hand-built scenes must pass every shared asset root in ``global_paths``; +use ``()`` when there are none. Under the Hood diff --git a/docs/source/migration/migrating_from_isaacgymenvs.rst b/docs/source/migration/migrating_from_isaacgymenvs.rst index 7fa1a5d0b5f1..aaed711a6d4b 100644 --- a/docs/source/migration/migrating_from_isaacgymenvs.rst +++ b/docs/source/migration/migrating_from_isaacgymenvs.rst @@ -231,7 +231,7 @@ adding any other optional objects into the scene, such as lights. | | spawn_ground_plane( | | | prim_path="/World/ground", cfg=GroundPlaneCfg()) | | self.sim = super().create_sim(self.device_id, self.graphics_device_id, | # create and apply a clone plan | -| self.physics_engine, self.sim_params) | plan = cloner.clone_plan_from_env_0(...) | +| self.physics_engine, self.sim_params) | plan = cloner.clone_plan_from_env_0(..., global_paths=...) | | self._create_ground_plane() | cloner.replicate(plan, stage=self.scene.stage) | | self._create_envs(self.num_envs, self.cfg["env"]['envSpacing'], | # add articulation to scene | | int(np.sqrt(self.num_envs))) | self.scene.articulations["cartpole"] = self.cartpole | @@ -680,8 +680,10 @@ the need to set simulation parameters for actors in the task implementation. | self.sim_params) | positions = cloner.grid_transforms( | | self._create_ground_plane() | self.scene.num_envs, self.scene.cfg.env_spacing, | | self._create_envs(self.num_envs, | device=self.device)[0] | -| self.cfg["env"]['envSpacing'], | plan = cloner.clone_plan_from_env_0( | -| int(np.sqrt(self.num_envs))) | src, dest, self.scene.num_envs, self.device, positions) | +| self.cfg["env"]['envSpacing'], | global_paths = ("/World/ground",) | +| int(np.sqrt(self.num_envs))) | plan = cloner.clone_plan_from_env_0( | +| | src, dest, self.scene.num_envs, self.device, positions, | +| | global_paths=global_paths) | | | cloner.replicate(plan, stage=self.scene.stage) | | def _create_ground_plane(self): | if "physx" in self.scene.physics_backend: | | plane_params = gymapi.PlaneParams() | self.scene.filter_collisions(global_prim_paths=[]) | diff --git a/scripts/demos/pick_and_place.py b/scripts/demos/pick_and_place.py index e23ee1a2450c..64158169aa92 100644 --- a/scripts/demos/pick_and_place.py +++ b/scripts/demos/pick_and_place.py @@ -228,7 +228,8 @@ def _setup_scene(self): spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/scripts/tutorials/06_deploy/anymal_c_env.py b/scripts/tutorials/06_deploy/anymal_c_env.py index 96b1324b5f30..a41a5158ee66 100644 --- a/scripts/tutorials/06_deploy/anymal_c_env.py +++ b/scripts/tutorials/06_deploy/anymal_c_env.py @@ -66,7 +66,8 @@ def _setup_scene(self): self._terrain = self.cfg.terrain.class_type(self.cfg.terrain) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = (self.cfg.terrain.prim_path,) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab/changelog.d/contact-sensor-regex.skip b/source/isaaclab/changelog.d/contact-sensor-regex.skip new file mode 100644 index 000000000000..641d81c85a68 --- /dev/null +++ b/source/isaaclab/changelog.d/contact-sensor-regex.skip @@ -0,0 +1 @@ +Docstring-only clarification of the shape-expression convention; behaviour change lives in isaaclab_newton. diff --git a/source/isaaclab/changelog.d/explicit-global-clone-plan.major.rst b/source/isaaclab/changelog.d/explicit-global-clone-plan.major.rst new file mode 100644 index 000000000000..4a6f526fd694 --- /dev/null +++ b/source/isaaclab/changelog.d/explicit-global-clone-plan.major.rst @@ -0,0 +1,11 @@ +Added +^^^^^ + +* Added :attr:`~isaaclab.cloner.ClonePlan.global_paths` to identify scene assets shared by every environment + without representing them as replication rows. + +Changed +^^^^^^^ + +* Changed :func:`~isaaclab.cloner.make_clone_plan`, :func:`~isaaclab.cloner.clone_plan_from_env_0`, and + :class:`~isaaclab.cloner.ReplicateSession` to accept explicit ``global_paths`` tuples. diff --git a/source/isaaclab/changelog.d/ooctipus-newton-active-solver-attributes.skip b/source/isaaclab/changelog.d/ooctipus-newton-active-solver-attributes.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/changelog.d/sylvesterkaczmarek-fix-depth-display-nonfinite.rst b/source/isaaclab/changelog.d/sylvesterkaczmarek-fix-depth-display-nonfinite.rst new file mode 100644 index 000000000000..64724b2a85f7 --- /dev/null +++ b/source/isaaclab/changelog.d/sylvesterkaczmarek-fix-depth-display-nonfinite.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed camera depth display normalization producing ``NaN`` values and suppressing finite depth contrast when + no-hit pixels contain ``inf`` or ``NaN`` values. diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py index 98e04ed311d7..3b47592ac026 100644 --- a/source/isaaclab/isaaclab/cloner/clone_plan.py +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -61,6 +61,9 @@ class ClonePlan: cfg_rows: dict[int, tuple[int, ...]] = field(default_factory=dict) """``id(cfg)`` to the row indices the cfg owns.""" + global_paths: tuple[str, ...] = () + """Unique prim paths for scene assets shared by every environment.""" + def grid_transforms(N: int, spacing: float = 1.0, up_axis: str = "z", device="cpu"): """Create a centered grid of transforms for ``N`` instances. @@ -221,7 +224,7 @@ def make_clone_plan( num_clones: int, env_spacing: float, device: str, - *, + global_paths: tuple[str, ...] = (), clone_strategy: Callable = sequential, valid_set: torch.Tensor | None = None, env_template: str = DEFAULT_ENV_TEMPLATE, @@ -234,15 +237,16 @@ def make_clone_plan( envs, and returns a self-contained :class:`ClonePlan` with ``cfg_rows`` populated. Each input cfg's ``spawn_path`` / ``spawn_paths`` is mutated so the subsequent - asset constructor spawns the prototype into its first active environment. Cfgs - whose ``prim_path`` is global (not under the env root ``/World/envs/``) or that - lack a spawn are skipped — they do not appear in the plan and are not replicated. + asset constructor spawns the prototype into its first active environment. Every cfg + is an env-scoped entity with a spawner. Shared assets are declared explicitly through + ``global_paths`` and are never replicated. Args: - cfgs: Asset cfgs with resolved ``prim_path`` (no ``{ENV_REGEX_NS}`` macros). + cfgs: Cloneable asset cfgs with resolved env-scoped ``prim_path`` and ``spawn``. num_clones: Number of target envs. env_spacing: Distance between neighboring grid env origins [m]. device: Torch device for plan tensors. + global_paths: Complete shared-asset roots declared by the scene composition root. Defaults to none. clone_strategy: Function that assigns prototype combinations to envs. Defaults to :func:`~isaaclab.cloner.sequential`. valid_set: Optional ``[num_combos, num_groups]`` long tensor of valid prototype @@ -251,8 +255,8 @@ def make_clone_plan( Returns: A :class:`ClonePlan` whose ``sources``/``destinations``/``clone_mask`` describe - the flat prototype-to-env mapping and whose ``cfg_rows`` maps each cfg to the - rows it owns. + the flat prototype-to-env mapping, whose ``cfg_rows`` maps each replicated cfg + to the rows it owns, and whose ``global_paths`` names shared scene assets. """ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: @@ -270,16 +274,11 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: # 1) Build per-group records: (cfg, spawn_cfg, destination_template, num_variants). groups: list[tuple[Any, Any, str, int]] = [] for cfg in cfgs: - if not hasattr(cfg, "prim_path") or not hasattr(cfg, "spawn") or cfg.spawn is None: - continue - prim_path = cfg.prim_path - if (matched := match(prim_path, env_template)) is None: - continue + matched = match(cfg.prim_path, env_template) count = num_spawn_variants(cfg.spawn) if count <= 0: - raise ValueError(f"Spawner at '{prim_path}' must have at least one variant.") + raise ValueError(f"Spawner at '{cfg.prim_path}' must have at least one variant.") groups.append((cfg, cfg.spawn, env_template + matched.suffix, count)) - env_ids = torch.arange(num_clones, dtype=torch.long, device=device) positions, _ = grid_transforms(num_clones, env_spacing, device=device) @@ -293,6 +292,7 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: env_ids=env_ids, positions=positions, cfg_rows={}, + global_paths=global_paths, ) # 3) Homogeneous (every cfg is single-variant): emit the simpler env-root plan. @@ -307,6 +307,7 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: env_ids=env_ids, positions=positions, cfg_rows=cfg_rows, + global_paths=global_paths, ) # 4) Heterogeneous: enumerate prototype combos, build per-row mask, mutate spawn paths. @@ -375,6 +376,7 @@ def validate_combo_tensor(combos: torch.Tensor, name: str, expected_rows: int | env_ids=env_ids, positions=positions, cfg_rows=cfg_rows, + global_paths=global_paths, ) @@ -384,14 +386,16 @@ def clone_plan_from_env_0( num_clones: int, device: str, positions: torch.Tensor | None = None, + global_paths: tuple[str, ...] = (), ) -> ClonePlan: """Build a single-source clone plan that targets every env from one source row. Auto-populates :attr:`ClonePlan.cfg_rows` from :data:`~isaaclab.cloner.REPLICATION_QUEUE`, including only cfgs whose ``prim_path`` falls under the env-root prefix of - ``destination``. Must be called *after* all asset constructors have run, so their cfgs - are already registered in the queue; otherwise those assets will be skipped by the - subsequent :func:`~isaaclab.cloner.replicate` call. + ``destination``. ``global_paths`` is the complete declaration of shared assets; it is + never inferred from the stage or replication queue. Must be called *after* all asset + constructors have run, so their cfgs are already registered in the queue; otherwise + those assets will be skipped by the subsequent :func:`~isaaclab.cloner.replicate` call. Args: source: Source prim path (typically ``/World/envs/env_0``). @@ -399,15 +403,14 @@ def clone_plan_from_env_0( num_clones: Number of target envs. device: Torch device for the mask and env id buffers. positions: Optional per-env world positions [m], shape ``[num_clones, 3]``. + global_paths: Complete shared-asset roots for the hand-built scene. Defaults to none. Returns: A :class:`ClonePlan` with a single source row covering every env. """ from .replicate_session import REPLICATION_QUEUE # noqa: PLC0415 - cfg_rows: dict[int, tuple[int, ...]] = { - id(cfg): (0,) for cfg in REPLICATION_QUEUE if match(cfg.prim_path, destination) is not None - } + cfg_rows = {id(cfg): (0,) for cfg in REPLICATION_QUEUE if match(cfg.prim_path, destination) is not None} return ClonePlan( sources=(source,), destinations=(destination,), @@ -415,4 +418,5 @@ def clone_plan_from_env_0( env_ids=torch.arange(num_clones, dtype=torch.long, device=device), positions=positions, cfg_rows=cfg_rows, + global_paths=global_paths, ) diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index 7efe316d1ce0..34dcb3afc94c 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -58,7 +58,8 @@ def replicate(plan: ClonePlan, *, stage: Usd.Stage, replicate_physics: bool = Tr Cfgs absent from ``plan.cfg_rows`` are silently skipped. Backend contexts run in ascending ``replicate_priority`` order. The queue is cleared up front, so a backend - failure cannot leak stale entries into the next call. + failure cannot leak stale entries into the next call. Every context receives the plan's + explicitly declared shared assets when it is constructed. Args: plan: Replication layout to dispatch. @@ -72,7 +73,7 @@ def replicate(plan: ClonePlan, *, stage: Usd.Stage, replicate_physics: bool = Tr REPLICATION_QUEUE.clear() backend_package = FactoryBase._get_package_name(FactoryBase._get_backend()) - backend_physics_ctx = getattr(importlib.import_module(f"{backend_package}.cloner"), "PHYSICS_CONTEXT", None) + backend_physics_ctx = importlib.import_module(f"{backend_package}.cloner").PHYSICS_CONTEXT # Group queued cfgs by backend, taking the union of row indices each backend owns. # In the homogeneous plan every cfg maps to row 0, so multiple queue_replication @@ -85,20 +86,20 @@ def replicate(plan: ClonePlan, *, stage: Usd.Stage, replicate_physics: bool = Tr if rows is None: continue if cfg.cloning_contexts is None: - contexts = [backend_physics_ctx] if backend_physics_ctx else [] + contexts = [backend_physics_ctx] else: contexts = [string_to_callable(c) if isinstance(c, str) else c for c in cfg.cloning_contexts] if not replicate_physics: contexts = [c for c in contexts if c is UsdReplicateContext] ctx_set = dict.fromkeys(contexts) - if getattr(cfg, "spawn", None) is not None and kit_available: + if cfg.spawn is not None and kit_available: ctx_set.setdefault(UsdReplicateContext, None) for BackendCtxCls in ctx_set: backend_rows.setdefault(BackendCtxCls, set()).update(rows) backend_ctxs: dict[type, Any] = {} for BackendCtxCls, row_set in backend_rows.items(): - ctx = BackendCtxCls(stage) + ctx = BackendCtxCls(stage, global_paths=plan.global_paths) backend_ctxs[BackendCtxCls] = ctx row_list = sorted(row_set) ctx.queue_mapping( @@ -109,7 +110,7 @@ def replicate(plan: ClonePlan, *, stage: Usd.Stage, replicate_physics: bool = Tr positions=plan.positions, ) - for ctx in sorted(backend_ctxs.values(), key=lambda c: getattr(c, "replicate_priority", 0)): + for ctx in sorted(backend_ctxs.values(), key=lambda ctx: ctx.replicate_priority): ctx.replicate() SimulationContext.instance().set_clone_plan(plan) @@ -139,6 +140,7 @@ def __init__( device: str, *, stage: Usd.Stage, + global_paths: tuple[str, ...] = (), clone_strategy: Callable = sequential, valid_set: torch.Tensor | None = None, replicate_physics: bool = True, @@ -152,6 +154,7 @@ def __init__( env_spacing: Grid spacing between env origins [m]. device: Torch device for plan tensors. stage: USD stage to author replicated prim specs into. + global_paths: Complete shared-asset roots declared by the composition root. Defaults to none. clone_strategy: Prototype-to-env assignment function. valid_set: Optional ``[num_combos, num_groups]`` long tensor of valid prototype combinations; ``None`` uses the full cartesian product. @@ -166,6 +169,7 @@ def __init__( num_clones=num_clones, env_spacing=env_spacing, device=device, + global_paths=global_paths, clone_strategy=clone_strategy, valid_set=valid_set, env_template=env_template, diff --git a/source/isaaclab/isaaclab/cloner/usd.py b/source/isaaclab/isaaclab/cloner/usd.py index 664702e15dce..187a00429154 100644 --- a/source/isaaclab/isaaclab/cloner/usd.py +++ b/source/isaaclab/isaaclab/cloner/usd.py @@ -30,7 +30,7 @@ class UsdReplicateContext: replicate_priority = 100 - def __init__(self, stage: Usd.Stage): + def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): """Initialize the context. Args: diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index d9cc5d5eb4ac..743f48c88220 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -86,6 +86,7 @@ class PhysicsManager(ABC): _sim_time: ClassVar[float] = 0.0 _callbacks: ClassVar[dict[int, tuple[Any, Callable, int, str | None, Any]]] = {} _callback_id: ClassVar[int] = 0 + views: ClassVar[dict[tuple[type, str], Any]] = {} @classmethod def _prepare_stage_creation(cls) -> None: @@ -457,6 +458,7 @@ def close(cls) -> None: cls.clear_callbacks() finally: if is_active_manager: + PhysicsManager.views.clear() PhysicsManager._sim = None PhysicsManager._cfg = None PhysicsManager._sim_time = 0.0 diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 80a6808a47f5..a7b2b77b7595 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -36,7 +36,7 @@ VisualMaterialCfg, ) from isaaclab.scene_data import REQUIRES_STAGE_AND_MODEL -from isaaclab.sensors import CameraCfg, ContactSensorCfg, FrameTransformerCfg, SensorBase, SensorBaseCfg +from isaaclab.sensors import CameraCfg, ContactSensorCfg, FrameTransformerCfg, RayCasterCfg, SensorBase, SensorBaseCfg from isaaclab.sim import SimulationContext from isaaclab.sim.utils.stage import get_current_stage, get_current_stage_id @@ -187,12 +187,13 @@ def __init__(self, cfg: InteractiveSceneCfg): # Always enter so a ClonePlan is published even when the scene cfg has no entities. self._global_prim_paths = list() - asset_cfgs = self._collect_asset_cfgs() + clone_cfgs, global_paths = self._collect_asset_cfgs() with cloner.ReplicateSession( - asset_cfgs, + clone_cfgs, num_clones=self.num_envs, env_spacing=self.cfg.env_spacing, device=self.device, + global_paths=global_paths, env_template=self._env_fmt, stage=self.stage, clone_strategy=self.cloner_cfg.clone_strategy, @@ -213,12 +214,12 @@ def __init__(self, cfg: InteractiveSceneCfg): if self.cfg.filter_collisions and "physx" in self.physics_backend and self._is_scene_setup_from_cfg(): self.filter_collisions(self._global_prim_paths) - def _collect_asset_cfgs(self) -> list[Any]: - """Flatten user-declared cfgs for :func:`~isaaclab.cloner.make_clone_plan`. + def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]: + """Flatten user-declared cfgs and declare shared prim roots for clone planning. Expands :class:`~isaaclab.assets.RigidObjectCollectionCfg` into its members, resolves ``{ENV_REGEX_NS}`` macros, lets an enclosing asset's row own nested materials, - and orders sensors after the entities they inspect. + and returns only env-scoped configs with a spawner. Global roots are returned separately. """ cfg_fields = InteractiveSceneCfg.__dataclass_fields__ @@ -230,16 +231,16 @@ def _collect_asset_cfgs(self) -> list[Any]: asset_cfg.rigid_objects.values() if isinstance(asset_cfg, RigidObjectCollectionCfg) else [asset_cfg] ) for child in children: - if hasattr(child, "prim_path"): - child.prim_path = cloner.expand_env_regex_ns(child.prim_path, self._env_fmt) + child.prim_path = cloner.expand_env_regex_ns(child.prim_path, self._env_fmt) flat_items.append((asset_name, child)) flat_items.sort(key=lambda item: isinstance(item[1], SensorBaseCfg)) owner_paths = [ cfg.prim_path for _name, cfg in flat_items - if not isinstance(cfg, (SensorBaseCfg, VisualMaterialCfg)) - and getattr(cfg, "spawn", None) is not None + if isinstance(cfg, AssetBaseCfg) + and not isinstance(cfg, VisualMaterialCfg) + and cfg.spawn is not None and cloner.path.match(cfg.prim_path, self._env_fmt) is not None ] nested_visual_material_ids = { @@ -252,6 +253,7 @@ def _collect_asset_cfgs(self) -> list[Any]: self._scene_asset_names = [name for name in self._scene_asset_names if name not in nested_material_names] cfgs: list[Any] = [] + global_paths: tuple[str, ...] = () clone_asset_names: list[str] = [] variant_counts: list[int] = [] for asset_name, child in flat_items: @@ -259,14 +261,13 @@ def _collect_asset_cfgs(self) -> list[Any]: if child.spawn is not None: child.spawn.spawn_path = child.prim_path continue - if ( - hasattr(child, "prim_path") - and getattr(child, "spawn", None) is not None - and cloner.path.match(child.prim_path, self._env_fmt) - ): + if cloner.path.match(child.prim_path, self._env_fmt) is None: + if child.prim_path not in global_paths: + global_paths += (child.prim_path,) + elif isinstance(child, (AssetBaseCfg, CameraCfg, RayCasterCfg)) and child.spawn is not None: + cfgs.append(child) clone_asset_names.append(asset_name) variant_counts.append(cloner.num_spawn_variants(child.spawn)) - cfgs.append(child) if self.cloner_cfg.clone_combinations and clone_asset_names: self._clone_valid_set = cloner.make_valid_clone_combinations( @@ -278,7 +279,7 @@ def _collect_asset_cfgs(self) -> list[Any]: ) else: self._clone_valid_set = None - return cfgs + return cfgs, global_paths def filter_collisions(self, global_prim_paths: list[str] | None = None): """Filter environments collisions. diff --git a/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_cfg.py b/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_cfg.py index 09a7c0228ff8..b40eb3ebc13c 100644 --- a/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_cfg.py +++ b/source/isaaclab/isaaclab/sensors/contact_sensor/contact_sensor_cfg.py @@ -98,6 +98,9 @@ class ContactSensorCfg(SensorBaseCfg): **Newton backend only** (ignored by the PhysX and OvPhysX backends). A shape is an individual collision geometry attached to a body. If non-empty, :attr:`prim_path` is ignored for the sensing objects and these shape expressions are used instead. + + Full-matched against shape paths, so an expression naming a body selects nothing: + write ``{ENV_REGEX_NS}/Box[^/]*/.*`` to reach the shapes below it. """ filter_shape_prim_expr: list[str] = [] @@ -106,6 +109,8 @@ class ContactSensorCfg(SensorBaseCfg): **Newton backend only** (ignored by the PhysX and OvPhysX backends). If provided, the force matrix reports per-shape contact forces; mutually exclusive with :attr:`filter_prim_paths_expr`. + + Matched against shape paths on the same terms as :attr:`sensor_shape_prim_expr`. """ visualizer_cfg: VisualizationMarkersCfg = CONTACT_SENSOR_MARKER_CFG.replace(prim_path="/Visuals/ContactSensor") diff --git a/source/isaaclab/isaaclab/utils/images.py b/source/isaaclab/isaaclab/utils/images.py index 480245ed6540..10a132a50d83 100644 --- a/source/isaaclab/isaaclab/utils/images.py +++ b/source/isaaclab/isaaclab/utils/images.py @@ -101,6 +101,7 @@ def normalize_camera_output_for_display(tensor: torch.Tensor, data_type: str) -> normalized = tensor.float() if data_type in ["depth", "distance_to_camera", "distance_to_image_plane"]: + normalized = torch.nan_to_num(normalized, nan=0.0, posinf=0.0, neginf=0.0) max_val = normalized.max() if max_val > 0: normalized = normalized / max_val diff --git a/source/isaaclab/test/cloner/test_clone_plan_algebra.py b/source/isaaclab/test/cloner/test_clone_plan_algebra.py index 6865b2e0fa4e..1e4f148086af 100644 --- a/source/isaaclab/test/cloner/test_clone_plan_algebra.py +++ b/source/isaaclab/test/cloner/test_clone_plan_algebra.py @@ -523,9 +523,9 @@ def test_query_agrees_across_duplicate_source_rows(): assert (cloner.query.path_to_clone(plan, path, env_id) is not None) == (env_id in reached) -## -# Plan invariants. -## +def test_env_0_plan_defaults_to_no_global_paths(): + plan = cloner.clone_plan_from_env_0("/World/envs/env_0", "/World/envs/env_{}", 2, "cpu") + assert plan.global_paths == () def test_query_and_path_are_real_modules(): diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index fb8be8e8af1a..638a0ed86fa8 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -35,7 +35,8 @@ class FakeUsdContext: replicate_priority = 100 instances: list["FakeUsdContext"] = [] - def __init__(self, stage): + def __init__(self, stage, *, global_paths): + self.global_paths = global_paths FakeUsdContext.instances.append(self) def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None): @@ -64,9 +65,12 @@ def replicate(self): env_ids=torch.arange(2, dtype=torch.long), positions=torch.zeros((2, 3)), cfg_rows={id(cfg): (0,)}, + global_paths=("/World/Ground", "/World/Light"), ) replicate_session.replicate(plan, stage=object()) assert len(FakeUsdContext.instances) == expected_instances + if FakeUsdContext.instances: + assert FakeUsdContext.instances[0].global_paths == ("/World/Ground", "/World/Light") assert published.plan is plan diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 6b44fefba646..0f37d95eb098 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -19,7 +19,7 @@ import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg -from isaaclab.assets import ArticulationCfg, RigidObjectCfg, RigidObjectCollectionCfg +from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg from isaaclab.cloner import CloneCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import ContactSensorCfg @@ -286,8 +286,8 @@ def test_cfg_cloning_contexts_override_backend_default(monkeypatch: pytest.Monke REPLICATION_QUEUE.clear() -def test_collect_asset_cfgs_resolves_env_regex_macros(): - """_collect_asset_cfgs rewrites {ENV_REGEX_NS} macros and expands collections.""" +def test_collect_asset_cfgs_resolves_env_regex_macros_and_declares_globals(): + """The composition root separates cloneable configs from shared prim roots.""" scene = object.__new__(InteractiveScene) cube_cfg = RigidObjectCfg( prim_path="{ENV_REGEX_NS}/Cube", @@ -302,30 +302,31 @@ def test_collect_asset_cfgs_resolves_env_regex_macros(): scene.cfg = SimpleNamespace( num_envs=2, objects=RigidObjectCollectionCfg(rigid_objects={"cube": cube_cfg, "shape": shape_cfg}), + ground=AssetBaseCfg(prim_path="/World/Ground", spawn=sim_utils.GroundPlaneCfg()), ) scene.cloner_cfg = CloneCfg() scene._env_fmt = scene.cloner_cfg.clone_template - cfgs = scene._collect_asset_cfgs() + cfgs, global_paths = scene._collect_asset_cfgs() prim_paths = sorted(c.prim_path for c in cfgs) assert prim_paths == ["/World/envs/env_[^/]+/Cube", "/World/envs/env_[^/]+/Shape"] + assert global_paths == ("/World/Ground",) -def test_collect_asset_cfgs_orders_sensors_last(): - """Non-sensor cfgs precede sensor cfgs in _collect_asset_cfgs output.""" +def test_collect_asset_cfgs_excludes_entities_without_spawners(): + """Only configs that can author clone sources reach make_clone_plan.""" scene = object.__new__(InteractiveScene) sensor = ContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") - body = SimpleNamespace(prim_path="{ENV_REGEX_NS}/Robot") - scene.cfg = SimpleNamespace(num_envs=1, sensor=sensor, body=body) + scene.cfg = SimpleNamespace(num_envs=1, sensor=sensor) scene.cloner_cfg = CloneCfg() scene._env_fmt = scene.cloner_cfg.clone_template - cfgs = scene._collect_asset_cfgs() + cfgs, global_paths = scene._collect_asset_cfgs() - # Sensors come after non-sensor entities so they can bind to spawned bodies. - assert cfgs.index(body) < cfgs.index(sensor) + assert cfgs == [] + assert global_paths == () def assert_state_equal(s1: dict, s2: dict, path=""): diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index 1b5352a8ea06..69f97aaf73fc 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -329,12 +329,12 @@ def test_make_clone_plan_homogeneous_returns_env_root_plan(sim): prim_path="/World/envs/env_[^/]+/Robot", spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), ) - plan = make_clone_plan( cfgs=[cube], num_clones=4, env_spacing=1.0, device=sim.cfg.device, + global_paths=("/World/Ground",), ) assert plan.sources == ("/World/envs/env_0",) @@ -342,6 +342,7 @@ def test_make_clone_plan_homogeneous_returns_env_root_plan(sim): assert plan.clone_mask.shape == (1, 4) assert plan.clone_mask.all() assert plan.cfg_rows[id(cube)] == (0,) + assert plan.global_paths == ("/World/Ground",) assert plan.env_ids.shape == (4,) assert plan.positions.shape == (4, 3) assert cube.spawn.spawn_path == "/World/envs/env_0/Robot" @@ -407,12 +408,12 @@ def test_make_clone_plan_heterogeneous_mutates_spawn_paths(sim): prim_path="/World/envs/env_[^/]+/Robot", spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), ) - plan = make_clone_plan( cfgs=[multi_cfg, plain_cfg], num_clones=4, env_spacing=1.0, device=sim.cfg.device, + global_paths=("/World/Ground",), clone_strategy=sequential, ) @@ -423,32 +424,30 @@ def test_make_clone_plan_heterogeneous_mutates_spawn_paths(sim): ) assert plan.cfg_rows[id(multi_cfg)] == (0, 1) assert plan.cfg_rows[id(plain_cfg)] == (2,) + assert plan.global_paths == ("/World/Ground",) assert multi_cfg.spawn.spawn_paths == ["/World/envs/env_0/Object", "/World/envs/env_1/Object"] assert plain_cfg.spawn.spawn_path == "/World/envs/env_0/Robot" -def test_make_clone_plan_skips_global_cfgs(sim): - """Cfgs whose prim_path is not under /World/envs/ are excluded from the plan.""" - global_cfg = SimpleNamespace( - prim_path="/World/global/Robot", - spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), - ) - +def test_make_clone_plan_records_globals_outside_replication_rows(sim): + """Global cfgs are named by the plan without becoming rows a backend might copy.""" plan = make_clone_plan( - cfgs=[global_cfg], + cfgs=[], num_clones=3, env_spacing=1.0, device=sim.cfg.device, + global_paths=("/World/global/Robot", "/World/ground"), ) assert plan.sources == () assert plan.destinations == () assert plan.clone_mask.shape == (0, 3) assert plan.cfg_rows == {} + assert plan.global_paths == ("/World/global/Robot", "/World/ground") -def test_clone_plan_from_env_0_populates_cfg_rows(sim): - """clone_plan_from_env_0 auto-maps queued env-scoped cfgs to row 0 and excludes global ones.""" +def test_clone_plan_from_env_0_populates_cfg_rows_and_global_paths(sim): + """The direct-env constructor separates replicated cfg rows from shared asset paths.""" env_cfg_a = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot") env_cfg_b = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object") global_cfg = SimpleNamespace(prim_path="/World/global/Light") @@ -457,17 +456,14 @@ def test_clone_plan_from_env_0_populates_cfg_rows(sim): cfg.cloning_contexts = (UsdReplicateContext,) queue_replication(cfg) - plan = cloner.clone_plan_from_env_0( - source="/World/envs/env_0", - destination="/World/envs/env_{}", - num_clones=4, - device=sim.cfg.device, - positions=grid_transforms(4, 1.0, device=sim.cfg.device)[0], - ) + src, dest = "/World/envs/env_0", "/World/envs/env_{}" + pos = grid_transforms(4, 1.0, device=sim.cfg.device)[0] + plan = cloner.clone_plan_from_env_0(src, dest, 4, sim.cfg.device, pos, global_paths=("/World/global/Light",)) assert plan.sources == ("/World/envs/env_0",) assert plan.destinations == ("/World/envs/env_{}",) assert plan.cfg_rows == {id(env_cfg_a): (0,), id(env_cfg_b): (0,)} + assert plan.global_paths == ("/World/global/Light",) assert plan.clone_mask.all() and plan.clone_mask.shape == (1, 4) assert torch.equal(plan.env_ids, torch.arange(4, dtype=torch.long, device=sim.cfg.device)) @@ -479,7 +475,7 @@ class FakePhysicsCtx: replicate_priority = 0 instances: list["FakePhysicsCtx"] = [] - def __init__(self, stage): + def __init__(self, stage, *, global_paths): FakePhysicsCtx.instances.append(self) def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None): @@ -491,7 +487,9 @@ def replicate(self): stage = sim_utils.get_current_stage() stage.DefinePrim("/World/envs/env_0/Robot", "Xform") cfg = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/Robot", cloning_contexts=(UsdReplicateContext, FakePhysicsCtx) + prim_path="/World/envs/env_[^/]+/Robot", + cloning_contexts=(UsdReplicateContext, FakePhysicsCtx), + spawn=None, ) REPLICATION_QUEUE.append(cfg) @@ -516,7 +514,7 @@ class FakeCtx: replicate_priority = 0 instances: list["FakeCtx"] = [] - def __init__(self, stage): + def __init__(self, stage, *, global_paths): self.stage = stage self.queue_calls: list[tuple] = [] self.replicate_calls = 0 @@ -528,8 +526,8 @@ def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None) def replicate(self): self.replicate_calls += 1 - cfg_a = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot") - cfg_b = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object") + cfg_a = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=None) + cfg_b = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object", spawn=None) cfg_a.cloning_contexts = (FakeCtx,) cfg_b.cloning_contexts = (FakeCtx,) REPLICATION_QUEUE.append(cfg_a) @@ -572,7 +570,7 @@ class FakeCtx: replicate_priority = 0 instances: list["FakeCtx"] = [] - def __init__(self, stage): + def __init__(self, stage, *, global_paths): self.queue_calls: list[tuple] = [] self.replicate_calls = 0 FakeCtx.instances.append(self) @@ -583,7 +581,7 @@ def queue_mapping(self, sources, destinations, env_ids, mask, *, positions=None) def replicate(self): self.replicate_calls += 1 - cfgs = [SimpleNamespace(prim_path=f"/World/envs/env_[^/]+/asset_{i}") for i in range(5)] + cfgs = [SimpleNamespace(prim_path=f"/World/envs/env_[^/]+/asset_{i}", spawn=None) for i in range(5)] for cfg in cfgs: cfg.cloning_contexts = (FakeCtx,) REPLICATION_QUEUE.append(cfg) @@ -616,7 +614,7 @@ def test_replicate_runs_lower_priority_backends_first(sim): class LowPriority: replicate_priority = 0 - def __init__(self, stage): + def __init__(self, stage, *, global_paths): pass def queue_mapping(self, *args, **kwargs): @@ -628,7 +626,7 @@ def replicate(self): class HighPriority: replicate_priority = 100 - def __init__(self, stage): + def __init__(self, stage, *, global_paths): pass def queue_mapping(self, *args, **kwargs): @@ -637,7 +635,7 @@ def queue_mapping(self, *args, **kwargs): def replicate(self): call_order.append("high") - cfg = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot") + cfg = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=None) cfg.cloning_contexts = (HighPriority, LowPriority) REPLICATION_QUEUE.append(cfg) @@ -684,7 +682,7 @@ def test_replicate_clears_queue_on_backend_failure(sim): class ExplodingCtx: replicate_priority = 0 - def __init__(self, stage): + def __init__(self, stage, *, global_paths): pass def queue_mapping(self, *args, **kwargs): @@ -693,7 +691,7 @@ def queue_mapping(self, *args, **kwargs): def replicate(self): raise RuntimeError("backend boom") - cfg = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot") + cfg = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=None) cfg.cloning_contexts = (ExplodingCtx,) REPLICATION_QUEUE.append(cfg) diff --git a/source/isaaclab/test/sim/test_physics_manager_lifecycle.py b/source/isaaclab/test/sim/test_physics_manager_lifecycle.py index 9d2b458d8632..b5120da49100 100644 --- a/source/isaaclab/test/sim/test_physics_manager_lifecycle.py +++ b/source/isaaclab/test/sim/test_physics_manager_lifecycle.py @@ -26,6 +26,7 @@ class TestManager(PhysicsManager): monkeypatch.setattr(PhysicsManager, "_sim", SimpleNamespace(physics_manager=TestManager)) monkeypatch.setattr(PhysicsManager, "_cfg", object()) monkeypatch.setattr(PhysicsManager, "_sim_time", 1.0) + monkeypatch.setattr(PhysicsManager, "views", {(TestManager, "/World/Robot"): object()}) TestManager.register_callback( lambda _payload: events.append("first"), @@ -71,6 +72,7 @@ def failing_listener(_payload): assert PhysicsManager._sim is None assert PhysicsManager._cfg is None assert PhysicsManager._sim_time == 0.0 + assert PhysicsManager.views == {} def test_close_surfaces_stop_errors_stored_by_safe_callback_invoke(monkeypatch): diff --git a/source/isaaclab/test/utils/test_images_display_nonfinite.py b/source/isaaclab/test/utils/test_images_display_nonfinite.py new file mode 100644 index 000000000000..1a196aa423bc --- /dev/null +++ b/source/isaaclab/test/utils/test_images_display_nonfinite.py @@ -0,0 +1,30 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +import pytest +import torch + +from isaaclab.utils.images import normalize_camera_output_for_display + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize("data_type", ["depth", "distance_to_camera", "distance_to_image_plane"]) +def test_depth_display_normalization_ignores_nonfinite_values(data_type): + src = torch.tensor([0.0, 2.0, float("inf"), 4.0, float("nan")]) + + out = normalize_camera_output_for_display(src, data_type) + + expected = torch.tensor([0.0, 0.5, 0.0, 1.0, 0.0]) + torch.testing.assert_close(out, expected) + assert torch.isfinite(out).all() + + +def test_depth_display_normalization_handles_all_nonfinite_values(): + src = torch.tensor([float("inf"), float("nan")]) + + out = normalize_camera_output_for_display(src, "depth") + + torch.testing.assert_close(out, torch.zeros_like(src)) diff --git a/source/isaaclab_contrib/changelog.d/ooctipus-newton-active-solver-attributes.rst b/source/isaaclab_contrib/changelog.d/ooctipus-newton-active-solver-attributes.rst new file mode 100644 index 000000000000..b6c9770e5e50 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/ooctipus-newton-active-solver-attributes.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Registered every configured child solver's builder attributes for coupled Newton models. diff --git a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py index 9cd385595783..2310dc664223 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py +++ b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py @@ -188,17 +188,15 @@ def _validate_resolved_entries( def _register_builder_attributes(cls, builder: ModelBuilder) -> None: """Register custom attributes required by nested coupled entries.""" super()._register_builder_attributes(builder) - solver_cfg = getattr(PhysicsManager._cfg, "solver_cfg", None) - if any(isinstance(entry.solver_cfg, MPMSolverCfg) for entry in getattr(solver_cfg, "entries", ())): - NewtonMPMManager._register_builder_attributes(builder) + for entry in PhysicsManager._cfg.solver_cfg.entries: + entry.solver_cfg.class_type._register_builder_attributes(builder) @classmethod def _prepare_builder_for_finalize(cls, builder: ModelBuilder) -> None: """Normalize kinematic colliders when a coupled entry uses implicit MPM.""" super()._prepare_builder_for_finalize(builder) - solver_cfg = getattr(PhysicsManager._cfg, "solver_cfg", None) - if any(isinstance(entry.solver_cfg, MPMSolverCfg) for entry in getattr(solver_cfg, "entries", ())): - NewtonMPMManager._prepare_builder_for_finalize(builder) + for entry in PhysicsManager._cfg.solver_cfg.entries: + entry.solver_cfg.class_type._prepare_builder_for_finalize(builder) @classmethod def _initialize_contacts(cls) -> None: diff --git a/source/isaaclab_contrib/isaaclab_contrib/custom_coupling/coupled_mjwarp_vbd_manager.py b/source/isaaclab_contrib/isaaclab_contrib/custom_coupling/coupled_mjwarp_vbd_manager.py index ddde78d10485..8430a050b761 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/custom_coupling/coupled_mjwarp_vbd_manager.py +++ b/source/isaaclab_contrib/isaaclab_contrib/custom_coupling/coupled_mjwarp_vbd_manager.py @@ -13,6 +13,8 @@ from newton import Contacts, Control, Model, State from newton.solvers import SolverBase, SolverMuJoCo, SolverVBD +from isaaclab.physics import PhysicsManager + from .kernels import _kernel_body_particle_reaction from .newton_manager_cfg import CoupledMJWarpVBDSolverCfg @@ -27,12 +29,11 @@ class NewtonCoupledMJWarpVBDManager(NewtonVBDManager): _rigid_solver: SolverMuJoCo | None = None _soft_solver: SolverVBD | None = None _coupling_mode: str | None = None + _builder_attribute_solvers = (SolverMuJoCo,) @classmethod def step(cls) -> None: """Step the physics simulation.""" - from isaaclab.physics import PhysicsManager - sim = PhysicsManager._sim if sim is None or not sim.is_playing(): return diff --git a/source/isaaclab_contrib/test/coupling/test_coupler.py b/source/isaaclab_contrib/test/coupling/test_coupler.py index 53c47c873105..131deb8b82f2 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler.py @@ -30,7 +30,7 @@ XPBDSolverCfg, ) from isaaclab_newton.physics.newton_manager import NewtonManager -from newton import ShapeFlags +from newton import ModelBuilder, ShapeFlags from newton.solvers.experimental.coupled import SolverCoupledADMM, SolverCoupledProxy from isaaclab_contrib.coupling import ( @@ -579,6 +579,23 @@ def test_mpm_entry_reuses_builder_lifecycle_hooks(monkeypatch): assert events == [("register", builder), ("finalize", builder)] +def test_nested_solvers_register_their_builder_attributes(monkeypatch): + """A coupled model declares the schemas consumed by each configured child solver.""" + builder = ModelBuilder() + solver_cfg = CouplerProxyCfg( + entries=[ + CouplerEntryCfg(name="rigid", solver_cfg=MJWarpSolverCfg()), + CouplerEntryCfg(name="media", solver_cfg=MPMSolverCfg()), + ] + ) + monkeypatch.setattr(coupler.PhysicsManager, "_cfg", SimpleNamespace(solver_cfg=solver_cfg)) + + NewtonCouplerManager._register_builder_attributes(builder) + + assert builder.has_custom_attribute("mujoco:condim") + assert builder.has_custom_attribute("mpm:young_modulus") + + def test_contact_initialization_prepares_coupled_solver_buffers(monkeypatch): """Entry-local contact buffers are allocated before graph capture.""" events: list[tuple[str, object | None]] = [] diff --git a/source/isaaclab_contrib/test/custom_coupling/test_manager.py b/source/isaaclab_contrib/test/custom_coupling/test_manager.py index 33033387ccf4..d05962e96437 100644 --- a/source/isaaclab_contrib/test/custom_coupling/test_manager.py +++ b/source/isaaclab_contrib/test/custom_coupling/test_manager.py @@ -5,16 +5,29 @@ """Unit tests for the custom coupling manager.""" +from types import SimpleNamespace from unittest.mock import MagicMock import pytest from isaaclab_newton.physics import MJWarpSolverCfg, VBDSolverCfg +from newton import ModelBuilder import isaaclab_contrib.custom_coupling.coupled_mjwarp_vbd_manager as manager_module from isaaclab_contrib.custom_coupling.coupled_mjwarp_vbd_manager import NewtonCoupledMJWarpVBDManager from isaaclab_contrib.custom_coupling.newton_manager_cfg import CoupledMJWarpVBDSolverCfg +def test_register_builder_attributes_includes_nested_solvers(monkeypatch: pytest.MonkeyPatch) -> None: + """The custom coupled manager delegates builder setup to both configured children.""" + cfg = CoupledMJWarpVBDSolverCfg() + monkeypatch.setattr(manager_module.PhysicsManager, "_cfg", SimpleNamespace(solver_cfg=cfg)) + builder = ModelBuilder() + + NewtonCoupledMJWarpVBDManager._register_builder_attributes(builder) + + assert builder.has_custom_attribute("mujoco:condim") + + def test_reset_forwards_to_both_subsolvers(monkeypatch: pytest.MonkeyPatch) -> None: """Reset the real sub-solvers instead of the dummy solver slot.""" rigid_solver = MagicMock() diff --git a/source/isaaclab_newton/changelog.d/contact-sensor-regex.rst b/source/isaaclab_newton/changelog.d/contact-sensor-regex.rst new file mode 100644 index 000000000000..7f0e8cf4a40c --- /dev/null +++ b/source/isaaclab_newton/changelog.d/contact-sensor-regex.rst @@ -0,0 +1,23 @@ +Fixed +^^^^^ + +* **Breaking:** Fixed Newton contact sensors matching their body and shape expressions as globs + instead of regular expressions, which silently dropped alternation and let the segment-safe + ``[^/]*`` cross path separators. Expressions are now compiled and full-matched, as + :func:`~isaaclab.utils.string.resolve_matching_names` already does elsewhere. An expression that + relied on the widened wildcard to reach the shapes below a body now selects nothing and fails at + sensor initialization; spell the descendant segments explicitly to migrate, so + ``sensor_shape_prim_expr=["{ENV_REGEX_NS}/Object[^/]*"]`` becomes + ``["{ENV_REGEX_NS}/Object[^/]*/.*"]``. The same applies to ``filter_shape_prim_expr``. +* **Breaking:** Removed the contact sensor's bare-label fallback, which rewrote a path expression + down to its final segment when no model label contained a separator. It dated from Newton's + pre-hierarchical label API. Spell body and shape expressions as full paths to migrate, so + ``["fingertip_.*"]`` becomes ``["{ENV_REGEX_NS}/Robot/fingertip_[^/]*/.*"]``. +* Migrated the Newton contact sensor off the deprecated ``sensing_obj_*`` names onto the + replacements Newton 1.4 introduced. + +Changed +^^^^^^^ + +* Built the shared shape BVH with collision geometry during model finalization when a raycast sensor is present, + instead of rebuilding the BVH when the sensor task initializes. diff --git a/source/isaaclab_newton/changelog.d/ooctipus-explicit-global-clone-plan.rst b/source/isaaclab_newton/changelog.d/ooctipus-explicit-global-clone-plan.rst new file mode 100644 index 000000000000..976ae77f123f --- /dev/null +++ b/source/isaaclab_newton/changelog.d/ooctipus-explicit-global-clone-plan.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed Newton replication to import the physics scene and explicitly declared + :attr:`isaaclab.cloner.ClonePlan.global_paths` without stage discovery. Hand-built clone plans must declare + every shared USD asset root. diff --git a/source/isaaclab_newton/changelog.d/ooctipus-newton-active-solver-attributes.rst b/source/isaaclab_newton/changelog.d/ooctipus-newton-active-solver-attributes.rst new file mode 100644 index 000000000000..a3dfd0b64848 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/ooctipus-newton-active-solver-attributes.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Registered builder attributes only for the active Newton solver instead of importing and allocating inactive + solver data. +* Reused target-mode resolution across identical articulation clones and one canonical articulation view between + each articulation and its joint-wrench sensor. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index cd56ee56f1bf..f3daf3e9cf3c 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging +import re import warnings from collections.abc import Sequence from typing import TYPE_CHECKING @@ -27,7 +28,7 @@ from isaaclab.assets.articulation import ordering_kernels from isaaclab.assets.articulation.base_articulation import BaseArticulation from isaaclab.physics import PhysicsEvent -from isaaclab.sim.utils.queries import path_expr_to_glob, resolve_matching_prims_from_source +from isaaclab.sim.utils.queries import resolve_matching_prims_from_source from isaaclab.utils.string import resolve_matching_names, resolve_matching_names_values from isaaclab.utils.version import get_isaac_sim_version, has_kit from isaaclab.utils.warp import ProxyArray @@ -84,10 +85,11 @@ def has_articulation_root_api(prim) -> bool: def _configure_builder_joint_target_modes(builder, cfg: ArticulationCfg) -> None: """Resolve configured actuator gains into Newton builder target modes before finalization.""" - root_prim_path_regex = path_expr_to_glob(_resolve_articulation_root_prim_path_expr(cfg)).replace("*", ".*") + root_prim_path_regex = _resolve_articulation_root_prim_path_expr(cfg) articulation_ids, _ = resolve_matching_names( root_prim_path_regex, builder.articulation_label, raise_when_no_match=False ) + source_dof_ids = None for articulation_id in articulation_ids: joint_start = builder.articulation_start[articulation_id] joint_end = builder.articulation_end[articulation_id] @@ -107,6 +109,11 @@ def _configure_builder_joint_target_modes(builder, cfg: ArticulationCfg) -> None dof_ids.append(dof_id) dof_names.append(joint_name if dof_end - dof_start == 1 else f"{joint_name}:{axis_index}") + if source_dof_ids is not None: + for source_dof_id, dof_id in zip(source_dof_ids, dof_ids, strict=True): + builder.joint_target_mode[dof_id] = builder.joint_target_mode[source_dof_id] + continue + for actuator_cfg in cfg.actuators.values(): matched_indices, matched_names = resolve_matching_names( actuator_cfg.joint_names_expr, dof_names, raise_when_no_match=False @@ -130,6 +137,7 @@ def _configure_builder_joint_target_modes(builder, cfg: ArticulationCfg) -> None if _is_implicit_actuator_cfg(actuator_cfg) else JointTargetMode.EFFORT ) + source_dof_ids = dof_ids class Articulation(BaseArticulation): @@ -3279,19 +3287,14 @@ def write_spatial_tendon_properties_to_sim_mask( """ def _initialize_impl(self): - # obtain global simulation view - self._physics_sim_view = SimulationManager.get_physics_sim_view() - root_prim_path_expr = _resolve_articulation_root_prim_path_expr(self.cfg) # -- articulation - self._root_view = ArticulationView( + self._root_view = SimulationManager.views[SimulationManager, root_prim_path_expr] = ArticulationView( SimulationManager.get_model(), - path_expr_to_glob(root_prim_path_expr), + re.compile(root_prim_path_expr), verbose=False, exclude_joint_types=[JointType.FREE, JointType.FIXED], ) - # Register view with Newton manager so sensors (e.g. FrameTransformer) can find it. - SimulationManager.get_physics_sim_view().append(self._root_view) # container for data access self._data = ArticulationData(self.root_view, self.device) diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py index 612e02d33f64..a6b233f4a874 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/newton_clone_utils.py @@ -11,7 +11,7 @@ import numpy as np import torch import warp as wp -from newton import GeoType, ModelBuilder, ShapeFlags, solvers +from newton import GeoType, ModelBuilder, ShapeFlags from pxr import Usd, UsdGeom, UsdPhysics @@ -133,8 +133,6 @@ def _build_source_builder( ) -> ModelBuilder: """Build one source builder.""" builder = create_builder() - solvers.SolverMuJoCo.register_custom_attributes(builder) - solvers.SolverKamino.register_custom_attributes(builder) import_result = builder.add_usd( stage, root_path=source, diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py index 3419e54f9dd4..8d0f201ffe38 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py @@ -105,6 +105,7 @@ def _build_newton_builder_from_mapping( quaternions: torch.Tensor | None = None, up_axis: str = "Z", load_visual_shapes: bool = True, + global_paths: tuple[str, ...] = (), ) -> tuple[ModelBuilder, object, dict, list, dict[str, ModelBuilder]]: """Build a Newton model builder from clone mapping inputs. @@ -122,16 +123,22 @@ def _build_newton_builder_from_mapping( manager_cls = PhysicsManager._sim.physics_manager builder = manager_cls.create_builder(up_axis=up_axis) - # Swap height-field-tagged terrain colliders for Newton heightfields before the - # mesh import, and skip those prims in add_usd so the terrain is not imported twice. - hf_ignore_paths = manager_cls._inject_terrain_heightfields(stage, builder) - stage_info = builder.add_usd( - stage, - ignore_paths=["/World/envs", *sources, *hf_ignore_paths], - schema_resolvers=schema_resolvers, - load_visual_shapes=load_visual_shapes, - ) - _restore_visible_colliders_without_visual_shapes(builder, stage, stage_info["path_shape_map"], load_visual_shapes) + import_paths = (PhysicsManager._sim.cfg.physics_prim_path, *global_paths) + hf_ignore_paths = manager_cls._inject_terrain_heightfields(stage, builder, root_paths=import_paths) + import_results = [] + for root_path in import_paths: + import_result = builder.add_usd( + stage, + root_path=root_path, + ignore_paths=hf_ignore_paths, + schema_resolvers=schema_resolvers, + load_visual_shapes=load_visual_shapes, + ) + _restore_visible_colliders_without_visual_shapes( + builder, stage, import_result["path_shape_map"], load_visual_shapes + ) + import_results.append(import_result) + stage_info = import_results[0] replace_newton_builder_shape_colors(builder, stage) if load_visual_shapes: import_builder_visual_material_paths(builder, stage) @@ -193,10 +200,12 @@ def _renderer_wants_visual_shapes() -> bool: class NewtonReplicateContext: """Queue and run Newton replication work for one stage.""" + replicate_priority = 0 + def __init__( self, stage: Usd.Stage, - *, + global_paths: tuple[str, ...] = (), device: str = "cpu", up_axis: str = "Z", load_visual_shapes: bool | None = None, @@ -206,6 +215,7 @@ def __init__( Args: stage: USD stage containing source assets. + global_paths: Shared scene-asset roots imported once outside replicated worlds. device: Device used by the finalized Newton model builder. up_axis: Up axis for the Newton model builder. load_visual_shapes: Whether to import visual-only geometry. If ``None``, @@ -215,6 +225,7 @@ def __init__( :class:`NewtonManager`. """ self.stage = stage + self._global_paths = global_paths self.device = device self.up_axis = up_axis if load_visual_shapes is None: @@ -308,6 +319,7 @@ def replicate(self) -> tuple[ModelBuilder, object, dict]: quaternions=quaternions, up_axis=self.up_axis, load_visual_shapes=self.load_visual_shapes, + global_paths=self._global_paths, ) fabric_body_bindings = rename_builder_labels(builder, sources, destinations, env_ids, mapping) if self.commit_to_manager: @@ -336,6 +348,7 @@ def newton_physics_replicate( quaternions: torch.Tensor | None = None, device: str = "cpu", up_axis: str = "Z", + global_paths: tuple[str, ...] = (), ): """Replicate prims into a Newton ``ModelBuilder`` using a per-source mapping. @@ -349,11 +362,12 @@ def newton_physics_replicate( quaternions: Optional per-environment orientations in xyzw order. device: Device used by the finalized Newton model builder. up_axis: Up axis for the Newton model builder. + global_paths: Shared scene-asset roots imported once. Defaults to none. Returns: Tuple of the populated Newton model builder and stage metadata. """ - ctx = NewtonReplicateContext(stage, device=device, up_axis=up_axis, commit_to_manager=True) + ctx = NewtonReplicateContext(stage, global_paths=global_paths, device=device, up_axis=up_axis) ctx.queue_mapping(sources, destinations, env_ids, mapping, positions=positions, quaternions=quaternions) builder, stage_info, _site_index_map = ctx.replicate() return builder, stage_info diff --git a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py index 517cf93dbf00..bd78aca9acbe 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/kamino_manager.py @@ -57,6 +57,8 @@ class NewtonKaminoManager(NewtonManager): # Annotate the concrete solver type. _solver: SolverKamino + _builder_attribute_solvers = (SolverKamino,) + @classmethod def _get_kamino_solver_cfg(cls) -> _KaminoSolverCfgBase: cfg = PhysicsManager._cfg diff --git a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py index 75e2b2e46991..ea1ab276970d 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/mjwarp_manager.py @@ -31,6 +31,8 @@ class NewtonMJWarpManager(NewtonManager): :attr:`NewtonCfg.debug_mode` is enabled. """ + _builder_attribute_solvers = (SolverMuJoCo,) + @classmethod def _create_solver(cls, model: Model, solver_cfg: MJWarpSolverCfg) -> SolverMuJoCo: """Construct the configured MuJoCo Warp solver.""" diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 7a1c65589b30..0765df0e3700 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -89,7 +89,7 @@ def _paused_gc(): ) from isaaclab.sim import SimulationContext from isaaclab.sim.utils.newton_model_utils import replace_newton_builder_shape_colors -from isaaclab.sim.utils.queries import has_deformable_curve_api, path_expr_to_glob +from isaaclab.sim.utils.queries import has_deformable_curve_api from isaaclab.sim.utils.stage import get_current_stage from isaaclab.utils import checked_apply from isaaclab.utils.string import resolve_matching_names @@ -120,6 +120,14 @@ def _paused_gc(): from isaaclab_newton.physics.newton_collision_cfg import NewtonCollisionPipelineCfg + +def _compile_label_pattern(expr: str | list[str] | None) -> re.Pattern[str] | None: + """Compile selector expressions for Newton's full label matching.""" + if not expr: + return None + return re.compile("|".join((expr,) if isinstance(expr, str) else expr)) + + logger = logging.getLogger(__name__) # Tagged union for entries in _cl_site_index_map. @@ -435,7 +443,7 @@ class NewtonManager(PhysicsManager): _sensor_state: State | None = None _sensor_state_dirty: bool = True _sensor_graph_capture_failed: bool = False - _sensor_bvh_has_collision_shapes: bool = False # set once a ray-cast sensor widens the shape BVH + _sensor_bvh_shape_flags: ShapeFlags = ShapeFlags.VISIBLE # USD/Fabric sync _newton_stage_path = None @@ -485,8 +493,7 @@ class NewtonManager(PhysicsManager): _shadow_deformable_batch_sync_key: tuple | None = None _visualization_stop_callback: CallbackHandle | None = None - # Views list for assets to register their views - _views: list = [] + _builder_attribute_solvers: tuple[type[SolverBase], ...] = () _mpm_object_registry: list = [] # CL: Cloning / Replication logic @@ -1045,15 +1052,8 @@ def register_callback( @classmethod def get_physics_sim_view(cls) -> list: - """Get the list of registered views. - - Assets can append their views to this list, and sensors can access them. - Returns a list that callers can append to. - - Returns: - List of registered views (e.g., NewtonArticulationView instances). - """ - return cls._views + """Return the registered articulation views.""" + return [view for (manager, _), view in cls.views.items() if manager is NewtonManager] @classmethod def is_fabric_enabled(cls) -> bool: @@ -1109,7 +1109,7 @@ def clear(cls): NewtonManager._sensor_state = None NewtonManager._sensor_state_dirty = True NewtonManager._sensor_graph_capture_failed = False - NewtonManager._sensor_bvh_has_collision_shapes = False + NewtonManager._sensor_bvh_shape_flags = ShapeFlags.VISIBLE NewtonManager._newton_stage_path = None NewtonManager._usdrt_stage = None NewtonManager._transforms_dirty = False @@ -1143,7 +1143,8 @@ def clear(cls): NewtonManager._cl_protos = {} NewtonManager._pending_extended_state_attributes = set() NewtonManager._pending_extended_contact_attributes = set() - NewtonManager._views = [] + for key in [key for key in NewtonManager.views if key[0] is NewtonManager]: + del NewtonManager.views[key] cls._solver_specific_clear() @classmethod @@ -1178,6 +1179,7 @@ def create_builder(cls, up_axis: str | None = None, **kwargs) -> ModelBuilder: mesh_constructor=cfg.bvh_constructor_geometry if isinstance(cfg, NewtonCfg) else None, gaussian_constructor=cfg.bvh_constructor_gaussian if isinstance(cfg, NewtonCfg) else None, shape_constructor=cfg.bvh_constructor_scene if isinstance(cfg, NewtonCfg) else None, + shape_flags=cls._sensor_bvh_shape_flags, ) cls._register_builder_attributes(builder) @@ -1187,17 +1189,9 @@ def create_builder(cls, up_axis: str | None = None, **kwargs) -> ModelBuilder: @classmethod def _register_builder_attributes(cls, builder: ModelBuilder) -> None: - """Subclass hook to register solver-specific custom attributes on *builder*. - - Override in solver subclasses (e.g. :class:`NewtonMPMManager`) that need - Newton-side particle, shape, or body custom attributes registered before - the builder is finalized. The default implementation is a no-op so - solvers without custom attributes do not need to override it. - - Implementations should be **idempotent** — the same builder may be - passed multiple times across :meth:`create_builder`, - :meth:`instantiate_builder_from_stage`, and :meth:`start_simulation`. - """ + """Register custom attributes required by the active solver.""" + for solver_cls in cls._builder_attribute_solvers: + solver_cls.register_custom_attributes(builder) @classmethod def _prepare_builder_for_finalize(cls, builder: ModelBuilder) -> None: @@ -1785,7 +1779,9 @@ def _initialize_fabric_particle_prims(stage, fabric_hierarchy, usdrt, prim_paths fabric_hierarchy.update_world_xforms() @classmethod - def _inject_terrain_heightfields(cls, stage: Usd.Stage, builder: ModelBuilder) -> list[str]: + def _inject_terrain_heightfields( + cls, stage: Usd.Stage, builder: ModelBuilder, *, root_paths: Sequence[str] + ) -> list[str]: """Replace height-field-tagged terrain colliders with Newton heightfields. Scans the stage for prims carrying the ``newton:heightfield:resolution`` @@ -1803,13 +1799,14 @@ def _inject_terrain_heightfields(cls, stage: Usd.Stage, builder: ModelBuilder) - Args: stage: The USD stage being imported. builder: The Newton model builder receiving the heightfield shapes. + root_paths: Concrete subtree roots to scan. Returns: Prim paths of terrain colliders that were converted to heightfields. """ ignore_paths: list[str] = [] xform_cache = UsdGeom.XformCache() - for prim in stage.Traverse(): + for prim in (prim for root_path in root_paths for prim in Usd.PrimRange(stage.GetPrimAtPath(root_path))): attr = prim.GetAttribute("newton:heightfield:resolution") if not attr or not attr.HasAuthoredValue(): continue @@ -1890,7 +1887,7 @@ def instantiate_builder_from_stage(cls): # ordering arguments are ever passed here, update the resolver # constants in lockstep or MJWarp resolution will silently diverge # from the live backend. - hf_ignore_paths = cls._inject_terrain_heightfields(stage, builder) + hf_ignore_paths = cls._inject_terrain_heightfields(stage, builder, root_paths=("/",)) solver_ignore_paths = cls._get_usd_import_ignore_paths() if not env_paths: @@ -2574,19 +2571,12 @@ def get_contacts(cls) -> Contacts | None: return cls._contacts @classmethod - def _register_sensor_task( - cls, name: str, update_fn: Callable[[], None], *, include_collision_shapes: bool = False - ) -> None: + def _register_sensor_task(cls, name: str, update_fn: Callable[[], None]) -> None: """Register a graph-capturable scene-query task. Args: name: Unique task name. update_fn: Graph-capturable callable run by :meth:`_update_sensor_tasks`. - include_collision_shapes: Whether the task must see collision-only - geometry. Newton builds the shape BVH over visible shapes, which is - what renderers want; the first ray-cast sensor rebuilds it with - collision shapes added, since those must be hit even when they carry - no visual representation. """ if name in cls._sensor_tasks: raise ValueError(f"Newton sensor task '{name}' is already registered.") @@ -2594,12 +2584,8 @@ def _register_sensor_task( state = cls.get_state_0() if model is None or state is None: raise RuntimeError("Registering a Newton sensor task requires an initialized model and state.") - if model.shape_count > 0: - if include_collision_shapes and not cls._sensor_bvh_has_collision_shapes: - model.bvh_build_shapes(state, shape_flags=ShapeFlags.VISIBLE | ShapeFlags.COLLIDE_SHAPES) - NewtonManager._sensor_bvh_has_collision_shapes = True - elif model.bvh_shapes is None: - model.bvh_build_shapes(state) + if model.shape_count > 0 and model.bvh_shapes is None: + model.bvh_build_shapes(state) if model.particle_count > 0 and model.bvh_particles is None: model.bvh_build_particles(state) cls._sensor_tasks[name] = update_fn @@ -3354,8 +3340,9 @@ def add_contact_sensor( ) -> tuple[str | list[str] | None, str | list[str] | None, str | list[str] | None, str | list[str] | None]: """Add a contact sensor for reporting contacts between bodies/shapes. - Converts Isaac Lab pattern conventions (``.*`` regex, full USD paths) to - fnmatch globs and delegates to :class:`newton.sensors.SensorContact`. + Compiles the Isaac Lab regular expressions and delegates to + :class:`newton.sensors.SensorContact`, which full-matches compiled patterns + against model labels. Args: body_names_expr: Expression for body names to sense. @@ -3383,31 +3370,6 @@ def add_contact_sensor( def _hashable_key(x): return tuple(x) if isinstance(x, list) else x - def _to_fnmatch(expr: str | list[str] | None) -> str | list[str] | None: - """Convert Isaac Lab regex expressions (``.*``) to fnmatch glob (``*``).""" - if expr is None: - return None - if isinstance(expr, str): - return path_expr_to_glob(expr) - return [path_expr_to_glob(p) for p in expr] - - def _normalize_for_labels(expr: str | list[str] | None, labels: list[str]) -> str | list[str] | None: - """Strip leading path components from *expr* when labels are bare names. - - Model labels may be full USD paths (``/World/envs/env_0/Robot/base``) or bare - names (``base``). When the labels are bare names but the user expression - contains slashes, we strip everything up to the last ``/``. - """ - if expr is None or not labels: - return expr - label_has_paths = any("/" in lbl for lbl in labels) - items = [expr] if isinstance(expr, str) else list(expr) - expr_uses_paths = any("/" in p for p in items) - if label_has_paths or not expr_uses_paths: - return expr - normalized = [p.rsplit("/", 1)[-1] for p in items] - return normalized[0] if isinstance(expr, str) else normalized - sensor_key = ( _hashable_key(body_names_expr), _hashable_key(shape_names_expr), @@ -3415,16 +3377,13 @@ def _normalize_for_labels(expr: str | list[str] | None, labels: list[str]) -> st _hashable_key(contact_partners_shape_expr), ) - body_labels = list(cls._model.body_label) - shape_labels = list(cls._model.shape_label) - with Timer(name="newton_contact_sensor", msg="Contact sensor construction took:"): sensor = NewtonContactSensor( cls._model, - sensing_obj_bodies=_normalize_for_labels(_to_fnmatch(body_names_expr), body_labels), - sensing_obj_shapes=_normalize_for_labels(_to_fnmatch(shape_names_expr), shape_labels), - counterpart_bodies=_normalize_for_labels(_to_fnmatch(contact_partners_body_expr), body_labels), - counterpart_shapes=_normalize_for_labels(_to_fnmatch(contact_partners_shape_expr), shape_labels), + sensing_bodies=_compile_label_pattern(body_names_expr), + sensing_shapes=_compile_label_pattern(shape_names_expr), + counterpart_bodies=_compile_label_pattern(contact_partners_body_expr), + counterpart_shapes=_compile_label_pattern(contact_partners_shape_expr), measure_total=True, verbose=verbose, ) diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py index fea4454d2d03..2b20638a4073 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor.py @@ -329,14 +329,14 @@ def _create_buffers(self): body_labels = self._get_model_labels("body") shape_labels = self._get_model_labels("shape") - s_kind = self.contact_view.sensing_obj_type + s_kind = self.contact_view.sensing_type if s_kind == "body": s_labels = body_labels elif s_kind == "shape": s_labels = shape_labels else: - raise RuntimeError(f"Unexpected Newton sensing_obj_type {s_kind!r}; expected 'body' or 'shape'.") - self._sensor_names = [s_labels[i].split("/")[-1] for i in self.contact_view.sensing_obj_idx] + raise RuntimeError(f"Unexpected Newton sensing_type {s_kind!r}; expected 'body' or 'shape'.") + self._sensor_names = [s_labels[i].split("/")[-1] for i in self.contact_view.sensing_indices] # Assumes the environments are processed in order. self._sensor_names = self._sensor_names[: self._num_sensors] diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_cfg.py b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_cfg.py index 0d5d376fa2a4..f15a51f15e6e 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/contact_sensor/contact_sensor_cfg.py @@ -60,7 +60,8 @@ def from_base_cfg(cls, base_cfg: BaseContactSensorCfg, **kwargs) -> "ContactSens Args: base_cfg: The base contact sensor configuration to copy from. - **kwargs: Newton-specific fields, e.g. ``filter_shape_prim_expr=["fingertip_.*"]``. + **kwargs: Newton-specific fields, e.g. + ``filter_shape_prim_expr=["{ENV_REGEX_NS}/Robot/fingertip_[^/]*/.*"]``. Returns: A new :class:`ContactSensorCfg` instance. diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py index 6ad958495e3c..b3226e2ca1de 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/joint_wrench/joint_wrench_sensor.py @@ -6,6 +6,7 @@ from __future__ import annotations import logging +import re from collections.abc import Sequence from typing import TYPE_CHECKING @@ -16,7 +17,7 @@ from pxr import UsdPhysics from isaaclab.sensors.joint_wrench import BaseJointWrenchSensor -from isaaclab.sim.utils.queries import path_expr_to_glob, resolve_matching_prims_from_source +from isaaclab.sim.utils.queries import resolve_matching_prims_from_source from isaaclab_newton.physics import NewtonManager @@ -125,20 +126,21 @@ def _initialize_impl(self) -> None: """PHYSICS_READY callback: builds the articulation view and binds model / state arrays.""" super()._initialize_impl() - model = NewtonManager.get_model() - state_0 = NewtonManager.get_state_0() + model, state_0 = NewtonManager.get_model(), NewtonManager.get_state_0() def has_articulation_root_api(prim) -> bool: return bool(prim.HasAPI(UsdPhysics.ArticulationRootAPI)) resolve_kwargs = {"predicate": has_articulation_root_api, "expected_num_matches": 1} _, root_prim_path_expr = resolve_matching_prims_from_source(self.cfg.prim_path, **resolve_kwargs)[0] - self._root_view = ArticulationView( - model, - path_expr_to_glob(root_prim_path_expr), - verbose=False, - exclude_joint_types=[JointType.FREE, JointType.FIXED], - ) + self._root_view = NewtonManager.views.get((NewtonManager, root_prim_path_expr)) + if self._root_view is None: + self._root_view = NewtonManager.views[NewtonManager, root_prim_path_expr] = ArticulationView( + model, + re.compile(root_prim_path_expr), + verbose=False, + exclude_joint_types=[JointType.FREE, JointType.FIXED], + ) self._num_joints = self._root_view.joint_count if self._num_joints == 0: raise RuntimeError( diff --git a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py index 717dd9c99895..04a519db26eb 100644 --- a/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py +++ b/source/isaaclab_newton/isaaclab_newton/sensors/ray_caster/newton_raycast_sensor.py @@ -273,6 +273,7 @@ class NewtonRaycastSensor(_NewtonRayCasterPoseMixin, BaseRayCaster): def __init__(self, cfg: NewtonRaycastSensorCfg): if cfg.max_distance <= 0.0: raise ValueError(f"max_distance must be positive, received {cfg.max_distance}.") + NewtonManager._sensor_bvh_shape_flags |= newton.ShapeFlags.COLLIDE_SHAPES super().__init__(cfg) self._data = NewtonRaycastSensorData() self._sensor_task_name: str | None = None @@ -326,7 +327,7 @@ def _initialize_impl(self) -> None: self._hit_normal = wp.empty(ray_count, dtype=wp.vec3f, device=self._device) self._sensor_task_name = f"newton_raycast:{self.cfg.prim_path}:{id(self)}" - NewtonManager._register_sensor_task(self._sensor_task_name, self._launch_raycast, include_collision_shapes=True) + NewtonManager._register_sensor_task(self._sensor_task_name, self._launch_raycast) def _launch_raycast(self) -> None: """Sensor pose + ray transform + BVH query + hit resolve (graph-capturable).""" diff --git a/source/isaaclab_newton/test/assets/test_articulation.py b/source/isaaclab_newton/test/assets/test_articulation.py index 42ef2d56fbc8..f29cb2920dcf 100644 --- a/source/isaaclab_newton/test/assets/test_articulation.py +++ b/source/isaaclab_newton/test/assets/test_articulation.py @@ -753,8 +753,8 @@ def test_actuator_cfg_matches_explicit_descendant_articulation_root(sim, device, @pytest.mark.parametrize("articulation_type", ["single_joint_implicit"]) @pytest.mark.parametrize("device", test_devices()) -def test_actuator_cfg_matches_clone_plan_root_glob(sim, device, articulation_type, monkeypatch): - """Match builder labels when clone-plan root resolution returns a glob.""" +def test_actuator_cfg_matches_clone_plan_root_expr(sim, device, articulation_type, monkeypatch): + """Match builder labels against the clone slot spelling clone-plan root resolution returns.""" articulation = Articulation( ArticulationCfg( prim_path="{ENV_REGEX_NS}/Robot", @@ -763,7 +763,7 @@ def test_actuator_cfg_matches_clone_plan_root_glob(sim, device, articulation_typ ) monkeypatch.setattr( "isaaclab_newton.assets.articulation.articulation.resolve_matching_prims_from_source", - lambda *_args, **_kwargs: [(None, "/World/envs/env_*/Robot/base")], + lambda *_args, **_kwargs: [(None, "/World/envs/env_[^/]+/Robot/base")], ) builder = _make_target_mode_builder(["joint"], [JointTargetMode.NONE], [0.0], [0.0]) builder.articulation_label = ["/World/envs/env_0/Robot/base"] diff --git a/source/isaaclab_newton/test/cloner/test_newton_builder_world_hook.py b/source/isaaclab_newton/test/cloner/test_newton_builder_world_hook.py index 1f030a1bcf49..858b7f9b83b9 100644 --- a/source/isaaclab_newton/test/cloner/test_newton_builder_world_hook.py +++ b/source/isaaclab_newton/test/cloner/test_newton_builder_world_hook.py @@ -3,13 +3,22 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Tests for scoped Newton per-world builder hooks.""" +"""Tests for Newton replication builder ownership.""" + +import importlib +from types import SimpleNamespace +from unittest import mock import newton import pytest +import torch from isaaclab_newton.cloner import copy_newton_clone_source, newton_builder_world_hook from isaaclab_newton.physics import NewtonManager +from pxr import Usd, UsdGeom, UsdLux, UsdPhysics + +replicate_module = importlib.import_module("isaaclab_newton.cloner.replicate") + def test_newton_builder_world_hook_owns_one_registration(monkeypatch): """The scope rejects duplicates and preserves unrelated hooks during cleanup.""" @@ -55,3 +64,50 @@ def test_copy_newton_clone_source_owns_mutable_geometry(monkeypatch): copied = copy_newton_clone_source("/World/Source") assert copied.shape_source[0] is not source.shape_source[0] + + +def test_explicit_global_import_uses_global_world(monkeypatch): + """Declared global colliders remain in Newton world -1 after model finalization.""" + stage = Usd.Stage.CreateInMemory() + UsdPhysics.Scene.Define(stage, "/physicsScene") + UsdGeom.Xform.Define(stage, "/World") + ground = UsdGeom.Cube.Define(stage, "/World/Ground") + UsdPhysics.CollisionAPI.Apply(ground.GetPrim()) + UsdLux.DistantLight.Define(stage, "/World/Light") + global_paths = ("/World/Ground", "/World/Light") + + builder = newton.ModelBuilder() + add_usd = mock.Mock(wraps=builder.add_usd) + monkeypatch.setattr(builder, "add_usd", add_usd) + manager = SimpleNamespace( + create_builder=mock.Mock(return_value=builder), _inject_terrain_heightfields=mock.Mock(return_value=[]) + ) + monkeypatch.setattr( + replicate_module.PhysicsManager, + "_sim", + SimpleNamespace(physics_manager=manager, cfg=SimpleNamespace(physics_prim_path="/physicsScene")), + ) + monkeypatch.setattr(replicate_module.NewtonManager, "_deformable_registry", ()) + monkeypatch.setattr(replicate_module.NewtonManager, "_cl_inject_sites", mock.Mock(return_value=({}, {}, {}))) + monkeypatch.setattr(replicate_module.NewtonManager, "_per_world_builder_hooks", ()) + monkeypatch.setattr(replicate_module, "replace_newton_builder_shape_colors", mock.Mock()) + + builder, *_ = replicate_module._build_newton_builder_from_mapping( + stage, + (), + (), + torch.arange(2), + torch.empty((0, 2), dtype=torch.bool), + global_paths=global_paths, + load_visual_shapes=False, + ) + + assert [call.kwargs["root_path"] for call in add_usd.call_args_list] == ["/physicsScene", *global_paths] + manager._inject_terrain_heightfields.assert_called_once_with( + stage, builder, root_paths=("/physicsScene", *global_paths) + ) + model = builder.finalize("cpu") + ground_index = model.shape_label.index("/World/Ground") + assert model.shape_world.numpy()[ground_index] == -1 + assert model.world_count == 2 + assert "/World/Light" not in model.shape_label # USD lights are not Newton physics entities. diff --git a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py index 9e0ab03702c6..ee81d39e83b7 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -532,8 +532,6 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) mock.patch.object(newton_clone_utils_module, "ModelBuilder", _FakeVisualizationModelBuilder), mock.patch.object(visualization_builder_module, "SchemaResolverNewton", lambda: object()), mock.patch.object(visualization_builder_module, "SchemaResolverPhysx", lambda: object()), - mock.patch.object(newton_clone_utils_module.solvers.SolverMuJoCo, "register_custom_attributes"), - mock.patch.object(newton_clone_utils_module.solvers.SolverKamino, "register_custom_attributes"), mock.patch.object(visualization_builder_module, "import_builder_visual_material_paths"), mock.patch.object(newton_clone_utils_module, "import_builder_visual_material_paths"), mock.patch.object(newton_clone_utils_module, "replace_newton_builder_shape_colors"), diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 60941a2cc040..cc52bd3492e7 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -25,12 +25,14 @@ from __future__ import annotations +from inspect import signature from types import SimpleNamespace import isaaclab_newton.physics.newton_manager as newton_manager_module import numpy as np import pytest import warp as wp +from isaaclab_newton.assets.articulation import articulation as articulation_module from isaaclab_newton.physics import ( FeatherstoneSolverCfg, KaminoDVICfg, @@ -55,8 +57,10 @@ XPBDSolverCfg, ) from isaaclab_newton.physics.mpm_manager import _make_solver_config +from newton import JointTargetMode, JointType, ModelBuilder, ShapeFlags from newton.solvers import SolverFeatherstone, SolverImplicitMPM, SolverKamino, SolverMuJoCo, SolverVBD, SolverXPBD +from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.physics import PhysicsManager from isaaclab.sim import SimulationCfg, build_simulation_context @@ -339,6 +343,30 @@ def render(): assert status["rendered"] +def test_sensor_bvh_shape_flags_are_fixed_before_builder_creation(monkeypatch): + """Builder finalization includes collision-only shapes without a later BVH rebuild.""" + import newton + + flags = ShapeFlags.VISIBLE | ShapeFlags.COLLIDE_SHAPES + monkeypatch.setattr(NewtonManager, "_sensor_bvh_shape_flags", flags) + monkeypatch.setattr(PhysicsManager, "_cfg", NewtonCfg()) + builder = NewtonManager.create_builder() + body = builder.add_body() + builder.add_shape_sphere(body, cfg=newton.ModelBuilder.ShapeConfig(is_visible=False)) + + model = builder.finalize(device="cpu") + + assert builder.default_bvh_cfg.shape_flags == flags + assert model.bvh_shape_count_enabled == 1 + assert model.bvh_shapes is not None + + +def test_sensor_task_registration_has_no_raycast_bvh_fallback(): + """Raycast BVH requirements belong to builder creation, not task registration.""" + assert "include_collision_shapes" not in signature(NewtonManager._register_sensor_task).parameters + assert not hasattr(NewtonManager, "_sensor_bvh_has_collision_shapes") + + def test_newton_shape_cfg_defaults_match_newton_shape_config(): """``NewtonShapeCfg`` contact defaults mirror Newton's ``ShapeConfig``. @@ -563,6 +591,30 @@ def test_mpm_register_builder_attributes_is_idempotent(): assert builder.has_custom_attribute("mpm:young_modulus") +@pytest.mark.parametrize( + ("manager", "active", "inactive"), + [ + (NewtonMJWarpManager, "mujoco:condim", ("kamino:max_solver_iterations", "mpm:young_modulus")), + (NewtonKaminoManager, "kamino:max_solver_iterations", ("mujoco:condim", "mpm:young_modulus")), + ], +) +def test_rigid_solver_registers_only_its_builder_attributes(manager, active, inactive): + """A rigid solver declares its own builder schema and no inactive solver schema.""" + builder = ModelBuilder() + + manager._register_builder_attributes(builder) + + assert builder.has_custom_attribute(active) + assert all(not builder.has_custom_attribute(name) for name in inactive) + + +def test_clone_source_builder_has_no_solver_dependency(): + """The active manager's builder factory, not the cloner, owns solver attributes.""" + import isaaclab_newton.cloner.newton_clone_utils as clone_utils + + assert not hasattr(clone_utils, "solvers") + + def test_mpm_prepare_builder_makes_kinematic_bodies_massless(): """Kinematic bodies must be massless so MPM treats them as kinematic colliders.""" import newton @@ -988,10 +1040,12 @@ def test_subclass_of_newton_manager(manager): def test_clear_resets_rigid_body_force_capability(monkeypatch): """Teardown clears the canonical solver capability without subclass shadowing.""" monkeypatch.setattr(NewtonManager, "_supports_rigid_body_force_input", True) + monkeypatch.setattr(NewtonManager, "_sensor_bvh_shape_flags", ShapeFlags.COLLIDE_SHAPES) NewtonManager.clear() assert NewtonManager._supports_rigid_body_force_input is False + assert NewtonManager._sensor_bvh_shape_flags == ShapeFlags.VISIBLE for manager in ( NewtonMJWarpManager, NewtonXPBDManager, @@ -1003,6 +1057,40 @@ def test_clear_resets_rigid_body_force_capability(monkeypatch): assert manager._supports_rigid_body_force_input is False +def test_articulation_target_modes_are_resolved_once_for_replicas(monkeypatch): + """Resolve target modes once, then copy them to the replicated articulations.""" + builder = SimpleNamespace( + articulation_label=["/World/Env_0/Robot", "/World/Env_1/Robot"], + articulation_start=[0, 1], + articulation_end=[1, 2], + joint_type=[JointType.REVOLUTE, JointType.REVOLUTE], + joint_qd_start=[0, 1], + joint_label=["/World/Env_0/Robot/joint", "/World/Env_1/Robot/joint"], + joint_target_mode=[int(JointTargetMode.NONE)] * 2, + joint_target_ke=[0.0] * 2, + joint_target_kd=[0.0] * 2, + ) + cfg = SimpleNamespace( + prim_path="/World/Env_[^/]*/Robot", + articulation_root_prim_path="", + actuators={"joint": ImplicitActuatorCfg(joint_names_expr=[".*"], stiffness=10.0, damping=0.0)}, + ) + original = articulation_module.resolve_matching_names + actuator_resolutions = 0 + + def count_actuator_resolutions(name_keys, names, *args, **kwargs): + nonlocal actuator_resolutions + if names == ["joint"]: + actuator_resolutions += 1 + return original(name_keys, names, *args, **kwargs) + + monkeypatch.setattr(articulation_module, "resolve_matching_names", count_actuator_resolutions) + articulation_module._configure_builder_joint_target_modes(builder, cfg) + + assert builder.joint_target_mode == [int(JointTargetMode.POSITION)] * 2 + assert actuator_resolutions == 1 + + @pytest.mark.parametrize( "native_path_active, native_graphable, expected_events", [ diff --git a/source/isaaclab_newton/test/physics/test_vbd_core.py b/source/isaaclab_newton/test/physics/test_vbd_core.py index e51b6405ca84..3604fa2d8fc7 100644 --- a/source/isaaclab_newton/test/physics/test_vbd_core.py +++ b/source/isaaclab_newton/test/physics/test_vbd_core.py @@ -144,7 +144,7 @@ def replicate(*args, **kwargs): monkeypatch.setattr( physics.NewtonVBDManager, "_inject_terrain_heightfields", - classmethod(lambda cls, stage, builder: ["/World/terrain"]), + classmethod(lambda cls, stage, builder, root_paths: ["/World/terrain"]), ) monkeypatch.setattr( physics.NewtonVBDManager, diff --git a/source/isaaclab_newton/test/sensors/test_contact_sensor.py b/source/isaaclab_newton/test/sensors/test_contact_sensor.py index d316d51152f4..5be5c3134b87 100644 --- a/source/isaaclab_newton/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_contact_sensor.py @@ -25,11 +25,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import math +import re import pytest import torch from flaky import flaky +from isaaclab_newton.physics.newton_manager import _compile_label_pattern from isaaclab_newton.sensors.contact_sensor import ContactSensorCfg as NewtonContactSensorCfg +from newton._src.utils.selection import match_labels from physics.physics_test_utils import ( COLLISION_PIPELINES, STABLE_SHAPES, @@ -44,6 +47,7 @@ import isaaclab.sim as sim_utils from isaaclab.assets import Articulation, RigidObject, RigidObjectCfg +from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors import ContactSensor, ContactSensorCfg from isaaclab.sim import build_simulation_context @@ -61,7 +65,7 @@ class ContactSensorTestSceneCfg(InteractiveSceneCfg): """Configuration for contact sensor test scenes.""" - terrain: TerrainImporterCfg | None = None + terrain: TerrainImporterCfg | None = TerrainImporterCfg(prim_path="/World/defaultGroundPlane", terrain_type="plane") object_a: RigidObjectCfg | None = None object_b: RigidObjectCfg | None = None object_c: RigidObjectCfg | None = None @@ -104,7 +108,7 @@ def test_contact_lifecycle(device: str, use_mujoco_contacts: bool, shape_type: S sim_cfg = make_sim_cfg(use_mujoco_contacts=use_mujoco_contacts, device=device, gravity=(0.0, 0.0, -gravity_mag)) - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: scene_cfg = ContactSensorTestSceneCfg(num_envs=num_envs, env_spacing=5.0, lazy_sensor_update=False) scene_cfg.object_a = create_shape_cfg( shape_type, @@ -230,6 +234,7 @@ def test_horizontal_collision_detects_contact(device: str, use_mujoco_contacts: 0.01 if use_mujoco_contacts and device.startswith("cuda") and shape_type == ShapeType.MESH_CAPSULE else 0.0 ) scene_cfg = ContactSensorTestSceneCfg(num_envs=num_envs, env_spacing=5.0, lazy_sensor_update=False) + scene_cfg.terrain = None scene_cfg.object_a = create_shape_cfg( shape_type, "{ENV_REGEX_NS}/ObjectA", @@ -328,7 +333,7 @@ def test_resting_object_contact_force(device: str, use_mujoco_contacts: bool): use_mujoco_contacts=use_mujoco_contacts, device=device, gravity=(0.0, 0.0, -gravity_magnitude) ) - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorTestSceneCfg(num_envs=num_envs, env_spacing=5.0, lazy_sensor_update=False) @@ -427,7 +432,7 @@ def test_higher_drop_produces_larger_impact_force(device: str, use_mujoco_contac sim_cfg = make_sim_cfg(use_mujoco_contacts=use_mujoco_contacts, device=device, gravity=(0.0, 0.0, -gravity_mag)) - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorTestSceneCfg(num_envs=num_envs, env_spacing=5.0, lazy_sensor_update=False) @@ -524,7 +529,7 @@ def test_filter_enables_force_matrix(device: str, use_mujoco_contacts: bool): sim_cfg = make_sim_cfg(use_mujoco_contacts=use_mujoco_contacts, device=device, gravity=(0.0, 0.0, -gravity)) - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorTestSceneCfg(num_envs=num_envs, env_spacing=5.0, lazy_sensor_update=False) @@ -647,7 +652,7 @@ def test_track_contact_points_reports_average_position(device: str, use_mujoco_c sim_cfg = make_sim_cfg(use_mujoco_contacts=use_mujoco_contacts, device=device, gravity=(0.0, 0.0, -gravity)) - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorTestSceneCfg(num_envs=num_envs, env_spacing=5.0, lazy_sensor_update=False) @@ -808,7 +813,7 @@ def test_finger_contact_sensor_isolation(device: str, use_mujoco_contacts: bool, sim_cfg = make_sim_cfg(use_mujoco_contacts=use_mujoco_contacts, device=device, gravity=(0.0, 0.0, 0.0)) - with build_simulation_context(sim_cfg=sim_cfg, add_ground_plane=True, add_lighting=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorTestSceneCfg(num_envs=num_envs, env_spacing=1.0, lazy_sensor_update=False) @@ -979,7 +984,7 @@ def test_sensor_metadata(device: str): sim_cfg = make_sim_cfg(use_mujoco_contacts=False, device=device, gravity=(0.0, 0.0, -9.81)) # (1) Body-mode, no filter: pattern matches two distinct body names per env. - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = _make_two_box_scene_cfg(num_envs) scene_cfg.contact_sensor_a = ContactSensorCfg( @@ -999,7 +1004,7 @@ def test_sensor_metadata(device: str): ) # (2) Body-mode, with filter: one body matches the sensor pattern, one matches the filter pattern. - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = _make_two_box_scene_cfg(num_envs) scene_cfg.contact_sensor_a = ContactSensorCfg( @@ -1020,13 +1025,15 @@ def test_sensor_metadata(device: str): # (3) Shape-mode, no filter: pattern matches shapes (not bodies). # `sensor_shape_prim_expr` is a Newton-only extension, so this block uses the - # backend-specific NewtonContactSensorCfg subclass. - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + # backend-specific NewtonContactSensorCfg subclass. Shape expressions are full-matched + # against shape paths, exactly as body expressions are against body paths, so the + # expression has to reach the shapes below the body (here ``BoxA/geometry/mesh``). + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = _make_two_box_scene_cfg(num_envs) scene_cfg.contact_sensor_a = NewtonContactSensorCfg( prim_path="{ENV_REGEX_NS}/Box[^/]*", - sensor_shape_prim_expr=["{ENV_REGEX_NS}/Box[^/]*"], + sensor_shape_prim_expr=["{ENV_REGEX_NS}/Box[^/]*/.*"], update_period=0.0, history_length=1, ) @@ -1051,7 +1058,7 @@ def test_sensor_print(): """Test that contact sensor print/repr works correctly.""" sim_cfg = make_sim_cfg(use_mujoco_contacts=False, device="cuda:0", gravity=(0.0, 0.0, -9.81)) - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorTestSceneCfg(num_envs=4, env_spacing=5.0, lazy_sensor_update=False) @@ -1088,7 +1095,7 @@ def test_no_stale_data_after_scene_reset(device: str): contact buffer here (it still reflects the previous step). """ sim_cfg = make_sim_cfg(use_mujoco_contacts=False, device=device, gravity=(0.0, 0.0, -9.81)) - with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True, add_ground_plane=True) as sim: + with build_simulation_context(sim_cfg=sim_cfg, auto_add_lighting=True) as sim: sim._app_control_on_stop_handle = None scene_cfg = ContactSensorTestSceneCfg(num_envs=1, env_spacing=2.0, lazy_sensor_update=False) @@ -1145,3 +1152,77 @@ def test_no_stale_data_after_scene_reset(device: str): assert torch.isnan(post_reset_contact_pos).all(), ( f"contact_pos_w should reset to NaN after scene.reset(); got {post_reset_contact_pos.tolist()}" ) + + +# =================================================================== +# Selector patterns +# =================================================================== + +_NS = DEFAULT_ENV_TEMPLATE.format("[^/]+") +"""The expansion of ``{ENV_REGEX_NS}``, as ``expand_env_regex_ns`` produces it.""" + +_LABELS = [ + "/World/envs/env_0/Robot/base", + "/World/envs/env_0/Robot/LF_FOOT", + "/World/envs/env_0/Robot/RF_FOOT", + "/World/envs/env_0/Robot/R_index_distal", + "/World/envs/env_0/Robot/Geometry/panda_link0", + "/World/envs/env_1/Robot/LF_FOOT", +] + +_SHAPE_LABELS = ["/World/envs/env_0/BoxA/geometry/mesh", "/World/envs/env_0/BoxB/mesh"] + + +def _select(expr, labels): + """Labels Newton selects for ``expr``.""" + pattern = _compile_label_pattern(expr) + return [labels[index] for index in match_labels(labels, pattern)] + + +def test_alternation_resolves(): + """A group selects its branches instead of matching literally.""" + assert _select(f"{_NS}/Robot/[^/]*R_(index|middle|pinky)_distal", _LABELS) == [ + "/World/envs/env_0/Robot/R_index_distal" + ] + + +def test_segment_wildcard_does_not_cross_path_separators(): + """``[^/]*`` selects one segment, so nested links stay out.""" + selected = _select(f"{_NS}/Robot/[^/]*", _LABELS) + + assert "/World/envs/env_0/Robot/base" in selected + assert "/World/envs/env_0/Robot/Geometry/panda_link0" not in selected + + +def test_expression_list_selects_the_union(): + """A list of expressions selects everything any one of them matches.""" + feet = ["/World/envs/env_0/Robot/LF_FOOT", "/World/envs/env_0/Robot/RF_FOOT"] + + # A single-element list is the shape every config takes, and carries alternation of its own. + assert _select([f"{_NS}/Robot/LF_FOOT|{_NS}/Robot/RF_FOOT"], _LABELS) == [ + *feet, + "/World/envs/env_1/Robot/LF_FOOT", + ] + assert _select([f"{_NS}/Robot/base", f"{_NS}/Robot/LF_FOOT|{_NS}/Robot/RF_FOOT"], _LABELS) == [ + "/World/envs/env_0/Robot/base", + *feet, + "/World/envs/env_1/Robot/LF_FOOT", + ] + + +def test_shape_expressions_match_on_the_same_terms_as_body_expressions(): + """Shape selectors carry no rule of their own.""" + assert _select(f"{_NS}/Box[^/]*", _SHAPE_LABELS) == [] + assert _select(f"{_NS}/Box[^/]*/.*", _SHAPE_LABELS) == _SHAPE_LABELS + + +@pytest.mark.parametrize("expr", [None, []]) +def test_absent_selector_compiles_to_no_pattern(expr): + """Nothing requested means unfiltered, not empty.""" + assert _compile_label_pattern(expr) is None + + +def test_invalid_expression_raises_regex_error(): + """Reject malformed selector expressions at contact sensor construction.""" + with pytest.raises(re.error): + _compile_label_pattern("foo(") diff --git a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py index b78c3ca65d0f..c0ccaecbd80e 100644 --- a/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_joint_wrench_sensor.py @@ -153,6 +153,7 @@ def test_initialization_and_shapes(sim): scene = InteractiveScene(_SingleJointSceneCfg(num_envs=2)) sim.reset() + robot: Articulation = scene["robot"] sensor: JointWrenchSensor = scene["wrench"] sim.step() scene.update(sim.get_physics_dt()) @@ -163,6 +164,7 @@ def test_initialization_and_shapes(sim): assert sensor.data.force.torch.shape == (num_envs, num_joints, 3) assert sensor.data.torque.torch.shape == (num_envs, num_joints, 3) assert sensor.body_names == ["Arm"] + assert sensor._root_view is robot.root_view # noqa: SLF001 def test_multi_body_articulation(sim): diff --git a/source/isaaclab_newton/test/sensors/test_newton_raycast_sensor.py b/source/isaaclab_newton/test/sensors/test_newton_raycast_sensor.py index f638ffa7f69c..60113d1819f9 100644 --- a/source/isaaclab_newton/test/sensors/test_newton_raycast_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_newton_raycast_sensor.py @@ -21,6 +21,7 @@ NewtonRaycastSensor, NewtonRaycastSensorCfg, ) +from newton import ShapeFlags import isaaclab.sim as sim_utils from isaaclab.assets import RigidObject, RigidObjectCfg @@ -122,6 +123,9 @@ def test_rays_hit_ground_plane(sim, global_world_only): scene_cfg = RaycastTestSceneCfg(num_envs=2) scene_cfg.raycast.global_world_only = global_world_only scene = InteractiveScene(scene_cfg) + expected_bvh_flags = ShapeFlags.VISIBLE | ShapeFlags.COLLIDE_SHAPES + assert NewtonManager._sensor_bvh_shape_flags == expected_bvh_flags + assert NewtonManager._builder.default_bvh_cfg.shape_flags == expected_bvh_flags sim.reset() sensor = _step_and_read(sim, scene) diff --git a/source/isaaclab_ov/changelog.d/explicit-global-clone-plan.skip b/source/isaaclab_ov/changelog.d/explicit-global-clone-plan.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py b/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py index a055300c582f..1a6f43bd2396 100644 --- a/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py +++ b/source/isaaclab_ov/isaaclab_ov/cloner/replicate.py @@ -78,7 +78,9 @@ def _validate_pose_rows(name: str, rows: list[list[float]] | None, env_ids: Sequ class OvPhysxReplicateContext: """Queue and run OvPhysX clone operations for one stage.""" - def __init__(self, stage: Usd.Stage): + replicate_priority = 0 + + def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): """Initialize the context. Args: diff --git a/source/isaaclab_ov/test/sensors/test_contact_sensor.py b/source/isaaclab_ov/test/sensors/test_contact_sensor.py index 39041b566257..a026044382c3 100644 --- a/source/isaaclab_ov/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_ov/test/sensors/test_contact_sensor.py @@ -573,13 +573,8 @@ def test_nested_rigid_body_hierarchy(device, num_envs): env_0.AddTranslateOp().Set(Gf.Vec3d(*env_positions[0].tolist())) _author_nested_chain("/World/envs/env_0/Robot") - clone_plan = cloner.clone_plan_from_env_0( - source="/World/envs/env_0", - destination="/World/envs/env_{}", - num_clones=num_envs, - device=device, - positions=env_positions, - ) + src, dest = "/World/envs/env_0", "/World/envs/env_{}" + clone_plan = cloner.clone_plan_from_env_0(src, dest, num_envs, device, env_positions) assert clone_plan.env_ids is not None ovphysx_replicate( stage, diff --git a/source/isaaclab_physx/changelog.d/explicit-global-clone-plan.skip b/source/isaaclab_physx/changelog.d/explicit-global-clone-plan.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/changelog.d/ooctipus-newton-active-solver-attributes.skip b/source/isaaclab_physx/changelog.d/ooctipus-newton-active-solver-attributes.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index 4cc47f75416d..563c94edf6f7 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -3858,9 +3858,9 @@ def has_articulation_root_api(prim) -> bool: resolve_kwargs = {"predicate": has_articulation_root_api, "expected_num_matches": 1} _, root_prim_path_expr = resolve_matching_prims_from_source(self.cfg.prim_path, **resolve_kwargs)[0] # -- articulation - self._root_view = self._physics_sim_view.create_articulation_view(path_expr_to_glob(root_prim_path_expr)) - - # check if the articulation was created + self._root_view = SimulationManager.views[SimulationManager, root_prim_path_expr] = ( + self._physics_sim_view.create_articulation_view(path_expr_to_glob(root_prim_path_expr)) + ) if self.root_view._backend is None: raise RuntimeError(f"Failed to create articulation at: {root_prim_path_expr}. Please check PhysX logs.") diff --git a/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py b/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py index 68a6ca45f31d..600265ef347e 100644 --- a/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py +++ b/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py @@ -26,7 +26,9 @@ def _select_env_ids(env_ids: torch.Tensor, mapping: torch.Tensor, row: int) -> t class PhysxReplicateContext: """Queue and run PhysX replication work for one stage.""" - def __init__(self, stage: Usd.Stage): + replicate_priority = 0 + + def __init__(self, stage: Usd.Stage, global_paths: tuple[str, ...] = ()): """Initialize the context. Args: diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index e3a306379a31..100c0e12decc 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -978,6 +978,8 @@ def _warmup_and_create_views(cls) -> None: @classmethod def _invalidate_views(cls) -> None: """Invalidate and clear simulation views.""" + for key in [key for key in cls.views if key[0] is cls]: + del cls.views[key] for view in (cls._view, cls._view_warp): if view: view.invalidate() diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py index 3099790ac5f9..e6597e85d107 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/joint_wrench/joint_wrench_sensor.py @@ -60,7 +60,6 @@ def __init__(self, cfg: JointWrenchSensorCfg): super().__init__(cfg) self._data = JointWrenchSensorData() - self._physics_sim_view = None self._root_view: physx.ArticulationView | None = None self._joint_pos_b: wp.array | None = None self._joint_quat_b: wp.array | None = None @@ -124,14 +123,18 @@ def _initialize_impl(self) -> None: """PHYSICS_READY callback: builds the articulation view and allocates buffers.""" super()._initialize_impl() - self._physics_sim_view = SimulationManager.get_physics_sim_view() - def has_articulation_root_api(prim) -> bool: return bool(prim.HasAPI(UsdPhysics.ArticulationRootAPI)) resolve_kwargs = {"predicate": has_articulation_root_api, "expected_num_matches": 1} _, root_prim_path_expr = resolve_matching_prims_from_source(self.cfg.prim_path, **resolve_kwargs)[0] - self._root_view = self._physics_sim_view.create_articulation_view(path_expr_to_glob(root_prim_path_expr)) + self._root_view = SimulationManager.views.get((SimulationManager, root_prim_path_expr)) + if self._root_view is None: + self._root_view = SimulationManager.views[SimulationManager, root_prim_path_expr] = ( + SimulationManager.get_physics_sim_view().create_articulation_view( + path_expr_to_glob(root_prim_path_expr) + ) + ) if self._root_view._backend is None: raise RuntimeError(f"Failed to create articulation view at: {root_prim_path_expr}. Check PhysX logs.") @@ -253,7 +256,6 @@ def _invalidate_initialize_callback(self, event) -> None: event: An invalidate event. """ super()._invalidate_initialize_callback(event) - self._physics_sim_view = None self._root_view = None self._joint_pos_b = None self._joint_quat_b = None diff --git a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py index be7909f007bb..41157e2a7c1a 100644 --- a/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py +++ b/source/isaaclab_physx/test/sensors/test_joint_wrench_sensor.py @@ -229,6 +229,7 @@ def test_initialization_and_shapes(sim): assert sensor.data.torque.torch.shape == (num_envs, num_bodies, 3) assert sensor.body_names == robot.body_names assert sensor.find_bodies("Arm") == ([robot.body_names.index("Arm")], ["Arm"]) + assert sensor._root_view is robot.root_view # noqa: SLF001 _assert_sensor_matches_physx_tensor(sensor) diff --git a/source/isaaclab_tasks/changelog.d/explicit-global-clone-plan.skip b/source/isaaclab_tasks/changelog.d/explicit-global-clone-plan.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/changelog.d/mtrepte-fix-flaky-sensor-physx-moving-clip.skip b/source/isaaclab_tasks/changelog.d/mtrepte-fix-flaky-sensor-physx-moving-clip.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py index 5fdd6949b196..c90d774297b7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/anymal_c_direct/anymal_c_env.py @@ -75,7 +75,8 @@ def _setup_scene(self): self._terrain = self.cfg.terrain.class_type(self.cfg.terrain) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = (self.cfg.terrain.prim_path,) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py index 7864dc8c8e02..720f22d97143 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/assembly_env.py @@ -261,7 +261,8 @@ def _setup_scene(self): self._held_asset = RigidObject(self.cfg_task.held_asset) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) self.scene.filter_collisions() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py index c115b8ae9b5d..d8d5f3b27749 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/automate/disassembly_env.py @@ -186,7 +186,8 @@ def _setup_scene(self): self._held_asset = RigidObject(self.cfg_task.held_asset) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) self.scene.filter_collisions() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py index 2f115f09ea7e..fdb880976d05 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/factory/factory_env.py @@ -100,7 +100,8 @@ def _setup_scene(self): self._large_gear_asset = Articulation(self.cfg_task.large_gear_cfg) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env.py index fccda95dc46a..a768df2f19a3 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/humanoid_amp/humanoid_amp_env.py @@ -67,7 +67,8 @@ def _setup_scene(self): ) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py index 2378183bcf21..481299efa446 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_env.py @@ -41,7 +41,8 @@ def _setup_scene(self): spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py index 483b0dd9fba4..99079c4f4edf 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/handover/handover_env.py @@ -98,7 +98,8 @@ def _setup_scene(self): spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py index 2416a95df9ab..07a7d233e447 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/locomotion/locomotion_direct_env.py @@ -67,7 +67,8 @@ def _setup_scene(self): self.terrain = self.cfg.terrain.class_type(self.cfg.terrain) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = (self.cfg.terrain.prim_path,) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_marl_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_marl_env.py index bf8bc954a353..75cce02ece4f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_marl_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/pendulum/pendulum_marl_env.py @@ -47,7 +47,8 @@ def _setup_scene(self): spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py index 8d54d49d50dc..32748e6e447e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/reorient_direct_env.py @@ -185,7 +185,8 @@ def _setup_scene(self): spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # PhysX replication requires explicit collision filtering between environments. if "physx" in self.scene.physics_backend: diff --git a/source/isaaclab_tasks/test/core/test_video_recording.py b/source/isaaclab_tasks/test/core/test_video_recording.py index 63f698d9682f..ba5744216b43 100644 --- a/source/isaaclab_tasks/test/core/test_video_recording.py +++ b/source/isaaclab_tasks/test/core/test_video_recording.py @@ -54,8 +54,8 @@ pytestmark = pytest.mark.isaacsim_ci -_CLIP = 12 # frames per clip -_STEPS = 22 # env steps: enough for one full clip plus close() flush +_CLIP = 20 # frames per clip +_STEPS = 30 # env steps: enough for one full clip plus close() flush _MIN_NONZERO_RATIO = 0.005 _MIN_MOTION_STD = 0.1 _SEED = 42 @@ -138,7 +138,8 @@ def _run_cartpole_camera(env_cfg) -> None: try: assert env.cfg.tiled_camera.renderer_cfg.renderer_type == "isaac_rtx" env.reset() - actions = torch.zeros(env.num_envs, *env.action_space.shape[1:], device=env.device) + # Nonzero action: guarantees clip motion instead of relying on passive pole fall. + actions = torch.ones(env.num_envs, *env.action_space.shape[1:], device=env.device) for _ in range(_STEPS): env.step(actions) finally: diff --git a/source/isaaclab_tasks_experimental/changelog.d/explicit-global-clone-plan.skip b/source/isaaclab_tasks_experimental/changelog.d/explicit-global-clone-plan.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/cartpole/cartpole_warp_env.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/cartpole/cartpole_warp_env.py index bde29608e439..7c184f9faf69 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/cartpole/cartpole_warp_env.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/cartpole/cartpole_warp_env.py @@ -237,7 +237,8 @@ def _setup_scene(self) -> None: spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # we need to explicitly filter collisions for CPU simulation if self.device == "cpu": diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/locomotion/locomotion_env_warp.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/locomotion/locomotion_env_warp.py index f9df06e3c292..38ab532a9fe8 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/locomotion/locomotion_env_warp.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/locomotion/locomotion_env_warp.py @@ -533,7 +533,8 @@ def _setup_scene(self) -> None: self.terrain = self.cfg.terrain.class_type(self.cfg.terrain) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = (self.cfg.terrain.prim_path,) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # add articulation and the feet wrench sensor to scene self.scene.articulations["robot"] = self.robot diff --git a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/reorient/reorient_warp_env.py b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/reorient/reorient_warp_env.py index 3dc091cc08de..09bc98c3225d 100644 --- a/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/reorient/reorient_warp_env.py +++ b/source/isaaclab_tasks_experimental/isaaclab_tasks_experimental/core/reorient/reorient_warp_env.py @@ -688,7 +688,8 @@ def _setup_scene(self): spawn_ground_plane(prim_path="/World/ground", cfg=GroundPlaneCfg()) src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # add articulation to scene - we must register to scene to randomize with EventManager self.scene.articulations["robot"] = self.hand diff --git a/tools/template/templates/tasks/direct_multi-agent/env b/tools/template/templates/tasks/direct_multi-agent/env index df00d46cfc73..936c1123d83f 100644 --- a/tools/template/templates/tasks/direct_multi-agent/env +++ b/tools/template/templates/tasks/direct_multi-agent/env @@ -39,7 +39,8 @@ class {{ task.classname }}Env(DirectMARLEnv): # build a homogeneous clone plan and replicate the env_0 layout to every env src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # we need to explicitly filter collisions for CPU simulation if self.device == "cpu": diff --git a/tools/template/templates/tasks/direct_single-agent/env b/tools/template/templates/tasks/direct_single-agent/env index 1d11cfc5ceb5..2c4b26725afe 100644 --- a/tools/template/templates/tasks/direct_single-agent/env +++ b/tools/template/templates/tasks/direct_single-agent/env @@ -61,7 +61,8 @@ class {{ task.classname }}Env(DirectRLEnv): # build a homogeneous clone plan and replicate the env_0 layout to every env src, dest = "/World/envs/env_0", "/World/envs/env_{}" pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing, device=self.device)[0] - plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos) + global_paths = ("/World/ground",) + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, self.device, pos, global_paths=global_paths) cloner.replicate(plan, stage=self.scene.stage) # we need to explicitly filter collisions for CPU simulation if self.device == "cpu":