From 74532cf8d619ffeb14d6e9df4df1256c58d42af9 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Sat, 22 Aug 2026 02:52:43 -0700 Subject: [PATCH 1/5] [Cloner] Scope Newton global imports with clone plans (#7292) # Description Newton replication currently calls `ModelBuilder.add_usd()` from the stage root and relies on ignore paths for replicated environments. This change moves global ownership to the scene composition root and carries it in `ClonePlan.global_paths`. - `InteractiveScene` returns env-scoped clone configs and an ordered `tuple[str, ...]` of shared prim roots. - `ClonePlan` carries that tuple without representing globals as clone rows. - Hand-built scenes pass non-empty globals explicitly; the ordinary no-global case defaults to `()`. - The replication pipeline passes the plan declaration directly to backend contexts. - Newton imports only the physics scene and each declared global root with `root_path=...`. - Terrain-heightfield discovery scans those same roots. There is no stage-root guessing, global-path registry, or full-stage fallback in clone-plan replication. ## Data flow The scene owns classification: ```python clone_cfgs, global_paths = self._collect_asset_cfgs() with ReplicateSession(clone_cfgs, ..., global_paths=global_paths, stage=stage): self._add_entities_from_cfg() ``` The plan carries the declaration: ```python ctx = BackendContext(stage, global_paths=plan.global_paths) ``` Newton performs narrow imports. For a plan declaring ground and light: ```python builder.add_usd(stage, root_path="/physicsScene") builder.add_usd(stage, root_path="/World/Ground") builder.add_usd(stage, root_path="/World/Light") ``` The physics-scene result remains the returned stage metadata; global-import result dictionaries are not merged. Globals stay outside `sources`, `destinations`, `clone_mask`, and `cfg_rows`, so they create no clone work or per-environment solver/sensor structures. PhysX collision filtering remains a separate post-clone concern based on `collision_group == -1`; global import ownership does not change that policy. ## Direct scenes Only scenes with shared roots need an explicit argument: ```python global_paths = ("/World/ground",) plan = clone_plan_from_env_0(src, dest, num_envs, device, positions, global_paths) ``` Scenes without shared roots keep the compact default: ```python plan = clone_plan_from_env_0(src, dest, num_envs, device, positions) ``` ## Startup benchmark RTX 5090, 4,096 environments, seed 42, Newton MJWarp, three post-warm-up runs per commit in alternating order. The baseline is `bc8b7bdf005`, immediately before scoped global import. Values are medians; positive deltas mean the PR is faster. | Workload | Metric | Baseline | PR | PR delta | |---|---|---:|---:|---:| | `Isaac-Velocity-Rough-AnymalD`, kitless | Scene creation | 4.932 s | 4.934 s | -0.05% | | | Environment creation | 13.511 s | 13.569 s | -0.43% | | | Total startup | 15.420 s | 15.520 s | -0.65% | | `Isaac-Lift-KukaAllegro`, kitless | Scene creation | 14.248 s | 14.202 s | +0.32% | | | Environment creation | 26.222 s | 26.184 s | +0.15% | | | Total startup | 28.875 s | 28.830 s | +0.16% | | `Isaac-Lift-KukaAllegro-Camera`, OVRTX RGB64 | Scene creation | 8.312 s | 8.232 s | +0.96% | | | Environment creation | 35.743 s | 35.782 s | -0.11% | | | Total startup | 39.936 s | 39.976 s | -0.10% | All differences are below 1%, so these workloads show no measurable gain. That is expected for the two kitless tasks because their USD stages do not contain materialized replicated environment trees. The warmed OVRTX workload also does not reproduce the earlier single-run estimate. A full Kit-stage comparison could not be run because Isaac Sim/Kit is not installed on the benchmark machine. That is the case where avoiding traversal of a materialized replicated USD tree should matter, and it remains unmeasured here. ## Testing - Clone-plan, replication-session, and Newton global-world coverage: `90 passed` on the final API. - Newton contact-sensor module after moving its ground into the scene declaration: `85 passed, 8 xpassed`. - Earlier full focused suite: `151 passed`. - Finalized-model coverage verifies the declared ground collider has Newton `shape_world == -1`; the declared light correctly creates no Newton physics entity. - 16-env Cartpole, Anymal-D, and Newton + OVRTX camera startup/reset/first-step checks passed. - Three 4,096-environment benchmark runs per workload and commit passed. - Repository formatting and pre-commit checks passed. The contact suite exposed the ownership boundary correctly: its ground had previously been spawned by `build_simulation_context()` outside the scene config and therefore was absent from the clone plan. The fix declares the ground in `ContactSensorTestSceneCfg`; the single intentionally groundless test sets `terrain = None`. No discovery fallback was added. ## Type of change - Performance/architecture improvement - Documentation update ## Checklist - [x] I have run the pre-commit checks. - [x] I have made corresponding changes to the documentation. - [x] My changes generate no new warnings. - [x] I have added tests that prove the feature works. - [x] I have added changelog fragments for changed packages. (cherry picked from commit 909cc5decc583f89af46acefc7feaec820525650) --- docs/source/how-to/cloning.rst | 14 +++-- .../migration/migrating_from_isaacgymenvs.rst | 8 ++- scripts/demos/pick_and_place.py | 3 +- scripts/tutorials/06_deploy/anymal_c_env.py | 3 +- .../explicit-global-clone-plan.major.rst | 11 ++++ source/isaaclab/isaaclab/cloner/clone_plan.py | 44 +++++++------- .../isaaclab/cloner/replicate_session.py | 16 +++-- source/isaaclab/isaaclab/cloner/usd.py | 2 +- .../isaaclab/scene/interactive_scene.py | 35 +++++------ .../test/cloner/test_clone_plan_algebra.py | 6 +- .../test/cloner/test_replicate_session.py | 6 +- .../test/scene/test_interactive_scene.py | 23 +++---- source/isaaclab/test/sim/test_cloner.py | 60 +++++++++---------- .../ooctipus-explicit-global-clone-plan.rst | 6 ++ .../isaaclab_newton/cloner/replicate.py | 38 ++++++++---- .../isaaclab_newton/physics/newton_manager.py | 9 ++- .../cloner/test_newton_builder_world_hook.py | 58 +++++++++++++++++- .../test/physics/test_vbd_core.py | 2 +- .../test/sensors/test_contact_sensor.py | 25 ++++---- .../explicit-global-clone-plan.skip | 0 .../isaaclab_ov/cloner/replicate.py | 4 +- .../test/sensors/test_contact_sensor.py | 9 +-- .../explicit-global-clone-plan.skip | 0 .../isaaclab_physx/cloner/replicate.py | 4 +- .../explicit-global-clone-plan.skip | 0 .../contrib/anymal_c_direct/anymal_c_env.py | 3 +- .../contrib/automate/assembly_env.py | 3 +- .../contrib/automate/disassembly_env.py | 3 +- .../contrib/factory/factory_env.py | 3 +- .../contrib/humanoid_amp/humanoid_amp_env.py | 3 +- .../core/cartpole/cartpole_direct_env.py | 3 +- .../core/handover/handover_env.py | 3 +- .../core/locomotion/locomotion_direct_env.py | 3 +- .../core/pendulum/pendulum_marl_env.py | 3 +- .../core/reorient/reorient_direct_env.py | 3 +- .../explicit-global-clone-plan.skip | 0 .../core/cartpole/cartpole_warp_env.py | 3 +- .../core/locomotion/locomotion_env_warp.py | 3 +- .../core/reorient/reorient_warp_env.py | 3 +- .../templates/tasks/direct_multi-agent/env | 3 +- .../templates/tasks/direct_single-agent/env | 3 +- 41 files changed, 278 insertions(+), 153 deletions(-) create mode 100644 source/isaaclab/changelog.d/explicit-global-clone-plan.major.rst create mode 100644 source/isaaclab_newton/changelog.d/ooctipus-explicit-global-clone-plan.rst create mode 100644 source/isaaclab_ov/changelog.d/explicit-global-clone-plan.skip create mode 100644 source/isaaclab_physx/changelog.d/explicit-global-clone-plan.skip create mode 100644 source/isaaclab_tasks/changelog.d/explicit-global-clone-plan.skip create mode 100644 source/isaaclab_tasks_experimental/changelog.d/explicit-global-clone-plan.skip 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/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/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/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/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_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/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/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 7a1c65589b30..548368a753ec 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -1785,7 +1785,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 +1805,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 +1893,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: 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/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..95aaca45d588 100644 --- a/source/isaaclab_newton/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_newton/test/sensors/test_contact_sensor.py @@ -61,7 +61,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 +104,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 +230,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 +329,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 +428,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 +525,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 +648,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 +809,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 +980,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 +1000,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( @@ -1021,7 +1022,7 @@ 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: + 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( @@ -1051,7 +1052,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 +1089,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) 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/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_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/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_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": From ba5c2af3f88c5a65f2d246c619593f68b9c939bf Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Sat, 22 Aug 2026 03:38:05 -0700 Subject: [PATCH 2/5] Fix flakey video recording test (#7285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description > [!IMPORTANT] > Confirm the pull request base before submitting. Target `develop` for all > contributions. The `release/3.0.0-beta2` branch is a frozen stable landing > snapshot and is not used for ongoing maintenance. To reduce flakiness in motion check in the video recording unit test - Add initial horizontal velocity to cart, instead of relying mostly on gravity affecting the pole to create motion - Extend the duration of the capture to allow for more motion ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there (cherry picked from commit 86cf66651bd881fed75ea0eb6b92fd57229dd6ae) --- .../mtrepte-fix-flaky-sensor-physx-moving-clip.skip | 0 source/isaaclab_tasks/test/core/test_video_recording.py | 7 ++++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/mtrepte-fix-flaky-sensor-physx-moving-clip.skip 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/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: From 55379f8410dd66093bee53421ba10a77c1e2a070 Mon Sep 17 00:00:00 2001 From: camevor Date: Sat, 22 Aug 2026 15:23:21 +0200 Subject: [PATCH 3/5] [Newton] Streamline contact and raycast sensor startup (#7269) ## Summary This PR is now the contact/raycast part of the Newton startup work: - compile contact-sensor full-path expressions once and match them directly, without regex-to-glob conversion or stage globbing; - use Newton's current contact-sensing API names; - declare raycast collision-shape requirements before model finalization, so the model builds one correctly configured BVH instead of rebuilding it during sensor initialization. The contact-selector and sensing-API commits retain Chris's (`camevor`) original authorship. The BVH lifecycle change incorporates the review from #7296 while keeping this as Chris's PR. The related ownership work is intentionally split by component: #7292 handles explicit global ownership, and #7295 handles model/articulation startup. This PR contains no inactive-solver registration or articulation-view changes and supersedes #7296. Production code is `+29/-56` (net `-27`) against `909cc5decc5`. ## Startup benchmark RTX 5090, CUDA device 1, 4096 environments, three fresh processes per revision/task. Values are median end-to-end startup wall time. Base: `909cc5decc5`. PR: `5d0e1b1595e`. | Task | Base | PR | Change | |---|---:|---:|---:| | `Isaac-Cartpole` | 8.316 s | 8.410 s | +1.1% | | `Isaac-Velocity-Rough-UnitreeGo2` | 16.363 s | 15.682 s | -4.2% | | `Isaac-Lift-KukaAllegro-Camera` | 39.473 s | 37.605 s | -4.7% | Cartpole has no contact/raycast workload here and is neutral within process-startup noise. The sensor-heavy tasks show the intended gain: | Median phase | Go2 base | Go2 PR | Kuka base | Kuka PR | |---|---:|---:|---:|---:| | `newton_contact_sensor` | 0.10 s | 0.04 s | 1.32 s | 0.24 s | | `simulation_start` | 6.36 s | 5.91 s | 11.48 s | 10.39 s | Raw end-to-end totals: - Cartpole base: 9.368, 8.316, 8.195 s; PR: 8.410, 8.370, 8.476 s. - Go2 rough base: 19.900, 16.363, 16.091 s; PR: 15.669, 15.682, 15.984 s. - Kuka camera base: 41.328, 38.625, 39.473 s; PR: 37.580, 37.605, 38.785 s. ## Test plan - `261 passed, 8 xpassed` across the Newton manager abstraction, contact-sensor, and raycast-sensor suites. - Repository formatting and pre-commit checks pass. - Architecture checks reject the removed regex-to-glob path, deprecated sensing names, duplicate BVH state, and late BVH fallback. - The three 4096-environment benchmark tasks also provide end-to-end Newton MJWarp startup coverage. ## Type of change - Performance improvement - Bug fix --------- Co-authored-by: Octi Zhang (cherry picked from commit 21bc111cef99066e5239f2d0975f3227d4046d49) --- .../changelog.d/contact-sensor-regex.skip | 1 + .../contact_sensor/contact_sensor_cfg.py | 5 ++ .../changelog.d/contact-sensor-regex.rst | 23 +++++ .../isaaclab_newton/physics/newton_manager.py | 73 +++++----------- .../sensors/contact_sensor/contact_sensor.py | 6 +- .../contact_sensor/contact_sensor_cfg.py | 3 +- .../ray_caster/newton_raycast_sensor.py | 3 +- .../test_newton_manager_abstraction.py | 28 +++++++ .../test/sensors/test_contact_sensor.py | 84 ++++++++++++++++++- .../sensors/test_newton_raycast_sensor.py | 4 + 10 files changed, 172 insertions(+), 58 deletions(-) create mode 100644 source/isaaclab/changelog.d/contact-sensor-regex.skip create mode 100644 source/isaaclab_newton/changelog.d/contact-sensor-regex.rst 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/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_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/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 548368a753ec..49d394e4a1e5 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 @@ -1109,7 +1117,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 @@ -1178,6 +1186,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) @@ -2577,19 +2586,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.") @@ -2597,12 +2599,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 @@ -3357,8 +3355,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. @@ -3386,31 +3385,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), @@ -3418,16 +3392,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/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/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 60941a2cc040..eea352a2cf3d 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -25,6 +25,7 @@ from __future__ import annotations +from inspect import signature from types import SimpleNamespace import isaaclab_newton.physics.newton_manager as newton_manager_module @@ -55,6 +56,7 @@ XPBDSolverCfg, ) from isaaclab_newton.physics.mpm_manager import _make_solver_config +from newton import ShapeFlags from newton.solvers import SolverFeatherstone, SolverImplicitMPM, SolverKamino, SolverMuJoCo, SolverVBD, SolverXPBD from isaaclab.physics import PhysicsManager @@ -339,6 +341,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``. @@ -988,10 +1014,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, diff --git a/source/isaaclab_newton/test/sensors/test_contact_sensor.py b/source/isaaclab_newton/test/sensors/test_contact_sensor.py index 95aaca45d588..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 @@ -1021,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. + # 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, ) @@ -1146,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_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) From c780f65538752bb3e4d73d36f06948e448a98c39 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:59:58 +0100 Subject: [PATCH 4/5] Fix non-finite depth display normalization (#7119) # Description `normalize_camera_output_for_display()` normalized depth images using the raw tensor maximum. Depth camera outputs may contain `inf` for no-hit pixels and can contain other non-finite values, so the maximum became non-finite; dividing by it could produce `NaN` pixels and suppress finite depth contrast. This change zeroes non-finite depth values before computing the display scale, preserving finite depth normalization while keeping no-hit pixels black. ## Validation Adds unit coverage for: - mixed finite, `inf`, and `NaN` depth values across supported depth display types; - all-non-finite depth input. ## Type of change - Bug fix --------- Signed-off-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Co-authored-by: Antoine RICHARD (cherry picked from commit 0a05bbd358fb3116f69e4ca2ab61df4b752eacdf) --- ...rkaczmarek-fix-depth-display-nonfinite.rst | 5 ++++ source/isaaclab/isaaclab/utils/images.py | 1 + .../utils/test_images_display_nonfinite.py | 30 +++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 source/isaaclab/changelog.d/sylvesterkaczmarek-fix-depth-display-nonfinite.rst create mode 100644 source/isaaclab/test/utils/test_images_display_nonfinite.py 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/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/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)) From 3ad2892b919a316b3fcee9fa25dad568d202a71b Mon Sep 17 00:00:00 2001 From: ooctipus Date: Sat, 22 Aug 2026 14:06:11 -0700 Subject: [PATCH 5/5] [Newton] Avoid repeated model startup work (#7295) ## Summary This is the model/articulation part of the scoped Newton startup work. It keeps startup work with the component that owns it: - each Newton manager declares only the custom builder schema used by its active solver; - articulation target modes are resolved for the prototype and copied to its replicas; - the base physics manager owns the articulation-view registry; - articulations create and register their view, while joint-wrench sensors reuse it or create it when used independently; - root expressions remain regular expressions instead of taking a lossy regex-to-glob round trip. The active-solver and reusable-view findings originated in Chris's #7269. This PR isolates those model/articulation changes so #7269 can remain the contact/raycast change under Chris's PR. This replaces the cloner's unconditional MuJoCo + Kamino registration and the duplicate joint-wrench view. There is no view scan, manager-specific cache API, compatibility fallback, or duplicate registry. The production diff against the current base is `+57/-60` (net `-3`). The explicit-global-ownership part is #7292. The contact/raycast part remains in #7269. ## Startup benchmark RTX 5090, CUDA device 1, 4096 environments, three fresh processes per revision/task. Values are median end-to-end startup wall time; raw runs are included below. Base: `86cf66651bd`. PR: `947869af173`. | Task | Base | PR | Change | |---|---:|---:|---:| | `Isaac-Cartpole` | 8.316 s | 7.655 s | -8.0% | | `Isaac-Velocity-Rough-UnitreeGo2` | 17.525 s | 14.844 s | -15.3% | | `Isaac-Lift-KukaAllegro-Camera` | 41.691 s | 36.832 s | -11.7% | Raw totals: - Cartpole base: 10.595, 8.190, 8.316 s; PR: 7.655, 7.674, 7.469 s. - Go2 rough base: 17.568, 17.525, 16.426 s; PR: 14.852, 14.844, 14.724 s. - Kuka camera base: 47.590, 41.691, 38.724 s; PR: 36.832, 36.666, 37.032 s. The measured `env_creation` medians improve from 6.473 to 5.801 s for Cartpole, 15.558 to 12.863 s for Go2 rough, and 37.481 to 32.578 s for Kuka camera. ## Test plan - `247 passed` across the physics-manager lifecycle, Newton cloner, manager abstraction, coupled-manager, and joint-wrench reuse tests. - `test_rename_builder_labels.py`: `17 passed` after removing obsolete solver-registration mocks. - Ruff check and format pass on all changed Python files. - The three 4096-environment benchmark tasks provide end-to-end Newton MJWarp startup coverage. (cherry picked from commit c4a275975788ff0c64b39de66ce799070b75fb1c) --- ...tipus-newton-active-solver-attributes.skip | 0 .../isaaclab/physics/physics_manager.py | 2 + .../sim/test_physics_manager_lifecycle.py | 2 + ...ctipus-newton-active-solver-attributes.rst | 4 ++ .../isaaclab_contrib/coupling/coupler.py | 10 ++- .../coupled_mjwarp_vbd_manager.py | 5 +- .../test/coupling/test_coupler.py | 19 +++++- .../test/custom_coupling/test_manager.py | 13 ++++ ...ctipus-newton-active-solver-attributes.rst | 7 +++ .../assets/articulation/articulation.py | 21 ++++--- .../cloner/newton_clone_utils.py | 4 +- .../isaaclab_newton/physics/kamino_manager.py | 2 + .../isaaclab_newton/physics/mjwarp_manager.py | 2 + .../isaaclab_newton/physics/newton_manager.py | 31 +++------- .../joint_wrench/joint_wrench_sensor.py | 20 +++--- .../test/assets/test_articulation.py | 6 +- .../test/cloner/test_rename_builder_labels.py | 2 - .../test_newton_manager_abstraction.py | 62 ++++++++++++++++++- .../test/sensors/test_joint_wrench_sensor.py | 2 + ...tipus-newton-active-solver-attributes.skip | 0 .../assets/articulation/articulation.py | 6 +- .../isaaclab_physx/physics/physx_manager.py | 2 + .../joint_wrench/joint_wrench_sensor.py | 12 ++-- .../test/sensors/test_joint_wrench_sensor.py | 1 + 24 files changed, 168 insertions(+), 67 deletions(-) create mode 100644 source/isaaclab/changelog.d/ooctipus-newton-active-solver-attributes.skip create mode 100644 source/isaaclab_contrib/changelog.d/ooctipus-newton-active-solver-attributes.rst create mode 100644 source/isaaclab_newton/changelog.d/ooctipus-newton-active-solver-attributes.rst create mode 100644 source/isaaclab_physx/changelog.d/ooctipus-newton-active-solver-attributes.skip 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/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/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_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/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/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 49d394e4a1e5..0765df0e3700 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -493,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 @@ -1053,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: @@ -1151,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 @@ -1196,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: 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/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_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 eea352a2cf3d..cc52bd3492e7 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -32,6 +32,7 @@ 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, @@ -56,9 +57,10 @@ XPBDSolverCfg, ) from isaaclab_newton.physics.mpm_manager import _make_solver_config -from newton import ShapeFlags +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 @@ -589,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 @@ -1031,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/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_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/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)