From 4a3c884ce0bfc088fce6194c4fb41ed3e2d40b64 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 2 Sep 2026 07:29:38 -0700 Subject: [PATCH 1/8] Publish ClonePlan before scene construction --- docs/source/how-to/cloning.rst | 32 ++++---- .../ooctipus-single-clone-lifecycle.major.rst | 7 ++ source/isaaclab/isaaclab/cloner/clone_plan.py | 18 +++-- .../isaaclab/cloner/replicate_session.py | 39 +++++++-- .../isaaclab/scene/interactive_scene.py | 79 ++++++++----------- .../isaaclab/sim/simulation_context.py | 29 +++++-- .../test/cloner/test_replicate_session.py | 61 +++++++++++++- .../test/scene/test_interactive_scene.py | 26 +++--- .../generate_synthetic_gaussian_asset.py | 9 --- source/isaaclab/test/sim/test_cloner.py | 4 +- 10 files changed, 198 insertions(+), 106 deletions(-) create mode 100644 source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index 84dd34ca1269..1faf46f5ce53 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -227,9 +227,8 @@ them is purely about ergonomics: ~~~~~~~~~~~~~~~~~~~~ :class:`~isaaclab.cloner.ReplicateSession` is a context manager that brackets the -whole cloning lifecycle. Entering the block builds the plan, the body is where -you construct your assets (each one registers itself as part of its constructor), -and exiting the block clears those constructor registrations and dispatches the plan: +whole cloning lifecycle. Entering the block builds and publishes the plan, the body +constructs assets at their planned source paths, and exiting dispatches that same plan: .. code-block:: python @@ -260,15 +259,14 @@ When envs need to differ across the population, use ``make_clone_plan`` + ``replicate`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The same two phases as the session, written as separate function calls. The plan -is built first, asset construction happens in between, and the drain runs -explicitly at the end. The gap between the build and the drain is the point — -that is where you can read the plan back, mutate it, log it, or otherwise -intervene before replication actually happens: +The same lifecycle as the session, written as separate function calls. Publish the +plan before construction so every participant observes the same layout, then +dispatch it after the prototypes exist: .. code-block:: python plan = cloner.make_clone_plan(cfgs, num_clones=N, env_spacing=2.0) + sim.set_clone_plan(plan) for cfg in cfgs: cfg.class_type(cfg) cloner.replicate(plan) @@ -323,18 +321,22 @@ execution contract: :func:`~isaaclab.cloner.replicate` resolves these types through the :class:`~isaaclab.sim.SimulationContext` backend registry, orders them by -``replicate_priority``, and passes the same plan to each one: +``replicate_priority``, and passes the published plan to each one: .. code-block:: python - def replicate(plan): - for context_type in plan.context_rows: - simulation_backends[context_type].replicate(plan) - publish(plan) + simulation.set_clone_plan(plan) + construct_prototypes() + for context_type in plan.context_rows: + simulation_backends[context_type].replicate(plan) + +The cfg-first lifecycle publishes before ``construct_prototypes()``. The direct +single-source workflow remains post-construction and is published by +:func:`~isaaclab.cloner.replicate` immediately before dispatch. In either form, +the simulation accepts one plan and each backend receives that exact object once. USD runs before native physics contexts so the destination topology exists when -they consume it. No fallback context is constructed during dispatch. The plan is -then published to the simulation context for downstream consumers. +they consume it. No fallback context is constructed during dispatch. Collision Filtering ------------------- diff --git a/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst b/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst new file mode 100644 index 000000000000..21b6159b59cc --- /dev/null +++ b/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* **Breaking:** Published cfg-owned clone plans before scene construction and limited each + :class:`~isaaclab.sim.SimulationContext` to one plan and one dispatch. Custom scene composition + roots should build and publish one plan before constructing its participants, then pass that same + plan once to :func:`~isaaclab.cloner.replicate`. diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py index e49815048c04..8632c40e0186 100644 --- a/source/isaaclab/isaaclab/cloner/clone_plan.py +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -203,7 +203,10 @@ def make_valid_clone_combinations( def _context_rows( - cfgs: tuple[Any, ...], cfg_rows: dict[int, tuple[int, ...]], populated_rows: set[int] + cfgs: tuple[Any, ...], + cfg_rows: dict[int, tuple[int, ...]], + populated_rows: set[int], + global_paths: tuple[str, ...] = (), ) -> dict[type[object], tuple[int, ...]]: """Route plan rows to the clone contexts registered for this simulation.""" sim = sim_utils.SimulationContext.instance() @@ -213,7 +216,9 @@ def _context_rows( physics_context = sim.physics_manager.clone_context_type if physics_context is not None and not isinstance(physics_context, type): raise TypeError("PhysicsManager.clone_context_type must be a context class.") - rows_by_context: dict[type[object], set[int]] = {} + rows_by_context: dict[type[object], set[int]] = ( + {} if physics_context is None or not global_paths else {physics_context: set()} + ) for cfg in cfgs: rows = cfg_rows.get(id(cfg)) @@ -236,7 +241,7 @@ def _context_rows( return { context_type: tuple(sorted(rows & populated_rows)) for context_type, rows in rows_by_context.items() - if rows & populated_rows + if rows & populated_rows or context_type is physics_context and bool(global_paths) } @@ -313,6 +318,7 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: env_ids=env_ids, positions=positions, cfg_rows={}, + context_rows=_context_rows(cfgs, {}, set(), global_paths), global_paths=global_paths, ) @@ -329,7 +335,7 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: env_ids=env_ids, positions=positions, cfg_rows=cfg_rows, - context_rows=_context_rows(cfgs, cfg_rows, {0}), + context_rows=_context_rows(cfgs, cfg_rows, {0}, global_paths), global_paths=global_paths, ) @@ -402,7 +408,7 @@ def validate_combinations(combos: np.ndarray, name: str, expected_rows: int | No env_ids=env_ids, positions=positions, cfg_rows=cfg_rows, - context_rows=_context_rows(cfgs, cfg_rows, populated_rows), + context_rows=_context_rows(cfgs, cfg_rows, populated_rows, global_paths), global_paths=global_paths, ) @@ -445,6 +451,6 @@ def clone_plan_from_env_0( env_ids=np.arange(num_clones, dtype=np.int64), positions=positions, cfg_rows=cfg_rows, - context_rows=_context_rows(queued, cfg_rows, {0}), + context_rows=_context_rows(queued, cfg_rows, {0}, global_paths), global_paths=global_paths, ) diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index f92bbd0f42ee..2d2bd9d2f654 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Post-construction clone-plan dispatch and :class:`ReplicateSession` sugar.""" +"""Clone-plan publication and dispatch.""" from __future__ import annotations @@ -17,6 +17,8 @@ from .clone_plan import make_clone_plan from .cloner_cfg import DEFAULT_ENV_TEMPLATE from .cloner_strategies import sequential +from .path import under +from .query import path_to_source from .usd import UsdReplicateContext if TYPE_CHECKING: @@ -32,16 +34,29 @@ def queue_replication(cfg: Any) -> None: - """Register a constructed cfg for post-construction clone planning. + """Register a constructed cfg or verify that the active plan owns it. Args: cfg: Asset cfg with resolved ``prim_path``. """ - REPLICATION_QUEUE.append(cfg) + sim = SimulationContext.instance() + plan = None if sim is None else sim.get_clone_plan() + if plan is None: + REPLICATION_QUEUE.append(cfg) + return + + prim_path = getattr(cfg, "prim_path", None) + global_owned = isinstance(prim_path, str) and any(under(prim_path, root) for root in plan.global_paths) + row_owned = isinstance(prim_path, str) and path_to_source(plan, prim_path) is not None + if not sim._clone_plan_consumed and (id(cfg) in plan.cfg_rows or global_owned or row_owned): + return + if getattr(cfg, "spawn", None) is None and (global_owned or row_owned): + return + raise RuntimeError(f"{type(cfg).__name__} at {prim_path!r} is not owned by the active ClonePlan.") def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: - """Dispatch a fully routed clone plan and publish it after replication. + """Publish and dispatch a fully routed clone plan. Planning derives routing from the input cfgs; dispatch does not rediscover or reshape that mapping. Every context is owned by the active :class:`~isaaclab.sim.SimulationContext` and receives @@ -57,6 +72,8 @@ def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: sim = SimulationContext.instance() if sim is None: raise RuntimeError("Clone-plan replication requires an active SimulationContext.") + sim._consume_clone_plan(plan) + context_types = tuple( context_type for context_type in plan.context_rows if replicate_physics or context_type is UsdReplicateContext ) @@ -68,14 +85,13 @@ def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: contexts = [sim._backend_registry[context_type] for context_type in context_types] for context in sorted(contexts, key=lambda item: item.replicate_priority): context.replicate(plan) - sim.set_clone_plan(plan) class ReplicateSession: """Folds :func:`make_clone_plan` and :func:`replicate` into a ``with`` block. - ``__enter__`` builds the complete plan and mutates each cfg's ``spawn_path``; - ``__exit__`` clears constructor registrations and dispatches that same plan. + ``__enter__`` builds and publishes the complete plan while assigning each cfg's + ``spawn_path``; ``__exit__`` dispatches that same plan. Example: @@ -125,7 +141,13 @@ def __init__( self._plan: ClonePlan | None = None def __enter__(self) -> ReplicateSession: + sim = SimulationContext.instance() + if sim is None: + raise RuntimeError("Clone planning requires an active SimulationContext.") + if sim.get_clone_plan() is not None: + raise RuntimeError("A SimulationContext owns exactly one clone lifecycle.") self._plan = make_clone_plan(self._cfgs, **self._kwargs) + sim.set_clone_plan(self._plan) return self def __exit__(self, exc_type, exc_value, traceback) -> None: @@ -135,6 +157,9 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: else: # Drop cfgs registered before the failure so the next session is clean. REPLICATION_QUEUE.clear() + sim = SimulationContext.instance() + if sim is not None and sim.get_clone_plan() is self._plan: + sim.set_clone_plan(None) @property def plan(self) -> ClonePlan: diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 337f33bda518..014371edffd8 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -171,34 +171,26 @@ def __init__(self, cfg: InteractiveSceneCfg): self._scene_asset_names: list[str] = [] self._clone_valid_set: np.ndarray | None = None - self._ALL_INDICES = torch.arange(self.cfg.num_envs, dtype=torch.long, device=self.device) - - # Always enter so a ClonePlan is published even when the scene cfg has no entities. self._global_prim_paths = list() clone_cfgs, global_paths = self._collect_asset_cfgs() - with cloner.ReplicateSession( - clone_cfgs, - num_clones=self.num_envs, - env_spacing=self.cfg.env_spacing, - global_paths=global_paths, - env_template=self._env_fmt, - clone_strategy=self.cloner_cfg.clone_strategy, - valid_set=self._clone_valid_set, - replicate_physics=self.cloner_cfg.replicate_physics, - ) as session: - self.stage.DefinePrim(self.env_prim_paths[0], "Xform") - with cloner.disabled_fabric_change_notifies(self.stage, restore=False): - cloner.usd_replicate( - self.stage, - [self.env_prim_paths[0]], - [self._env_fmt], - session.plan.env_ids, - positions=session.plan.positions, - ) - if self._is_scene_setup_from_cfg(): + scene_from_cfg = bool(self._scene_asset_names) + if scene_from_cfg: + with cloner.ReplicateSession( + clone_cfgs, + num_clones=self.num_envs, + env_spacing=self.cfg.env_spacing, + global_paths=global_paths, + env_template=self._env_fmt, + clone_strategy=self.cloner_cfg.clone_strategy, + valid_set=self._clone_valid_set, + replicate_physics=self.cloner_cfg.replicate_physics, + ) as session: + self._author_envs(session.plan.env_ids, session.plan.positions) self._add_entities_from_cfg() - self._env_origins_plan = session.plan - self._env_origins = torch.as_tensor(session.plan.positions, device=self.device) + else: + env_ids = np.arange(self.num_envs, dtype=np.int64) + env_origins = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing)[0] + self._author_envs(env_ids, env_origins) # Every sensor exists by now, so all visualizer and camera-renderer requirements are visible. cam_types = [s.cfg.renderer_cfg.renderer_type for s in self._sensors.values() if isinstance(s.cfg, CameraCfg)] @@ -208,7 +200,7 @@ def __init__(self, cfg: InteractiveSceneCfg): self.sim.requires_newton_model |= requires_model # Collision filtering is PhysX-only (matches both physx and ovphysx). - if self.cfg.filter_collisions and "physx" in self.physics_backend and self._is_scene_setup_from_cfg(): + if self.cfg.filter_collisions and "physx" in self.physics_backend and scene_from_cfg: self.filter_collisions(self._global_prim_paths) def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]: @@ -375,18 +367,11 @@ def num_envs(self) -> int: @property def env_origins(self) -> torch.Tensor: - """Per-env world origins, shape ``(num_envs, 3)``. From the terrain when registered, - else from the published :class:`~isaaclab.cloner.ClonePlan`. - """ + """Per-env world origins, shape ``(num_envs, 3)``.""" if self._terrain is not None: return self._terrain.env_origins - plan = self.clone_plan - if plan is None or plan.positions is None: - raise RuntimeError("Environment origins require a published clone plan with positions.") - if plan is not self._env_origins_plan: - self._env_origins = torch.as_tensor(plan.positions, device=self.device) - self._env_origins_plan = plan - return self._env_origins + plan = self.sim.get_clone_plan() + return torch.as_tensor(plan.positions, device=self.device) if plan is not None else self._env_origins @property def terrain(self) -> TerrainImporter | None: @@ -440,11 +425,12 @@ def visual_materials(self) -> dict[str, VisualMaterial]: @property def clone_plan(self) -> cloner.ClonePlan | None: - """Clone plan produced by the most recent replication. + """Clone plan owned by the active simulation. Forwards to :meth:`SimulationContext.get_clone_plan`, which is the canonical owner. The plan records the source paths, destination templates, and the per-env source - assignment mask. ``None`` until :func:`isaaclab.cloner.replicate` has run. + assignment mask. Cfg-owned scenes publish it before constructing their entities; + direct scenes publish it when their explicit clone lifecycle begins. """ return self.sim.get_clone_plan() @@ -789,16 +775,13 @@ def __getitem__(self, key: str) -> Any: Internal methods. """ - def _is_scene_setup_from_cfg(self) -> bool: - """Check if scene entities are setup from the config or not. - - Returns: - True if scene entities are setup from the config, False otherwise. - """ - return any( - not (asset_name in InteractiveSceneCfg.__dataclass_fields__ or asset_cfg is None) - for asset_name, asset_cfg in self.cfg.__dict__.items() - ) + def _author_envs(self, env_ids: np.ndarray, positions: np.ndarray) -> None: + """Author environment roots from the active layout.""" + self._ALL_INDICES = torch.as_tensor(env_ids, device=self.device) + self._env_origins = torch.as_tensor(positions, device=self.device) + self.stage.DefinePrim(self.env_prim_paths[0], "Xform") + with cloner.disabled_fabric_change_notifies(self.stage, restore=False): + cloner.usd_replicate(self.stage, [self.env_prim_paths[0]], [self._env_fmt], env_ids, positions=positions) def _add_entities_from_cfg(self): # noqa: C901 """Add scene entities from the config.""" diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 37ab8bb62850..05152f77f758 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -193,10 +193,10 @@ def __init__(self, cfg: SimulationCfg | None = None): # Set by the visualizers and renderers in use; read by the scene data provider. self.requires_usd_stage = False self.requires_newton_model = False - # Clone plan published by InteractiveScene after cloning. Providers (e.g. the - # Newton visualizer model rebuilder on a PhysX backend) consume this to derive - # their own backend args. None until a replication session publishes a plan. + # Clone plan published before cfg-owned scene construction. Constructors and + # backends therefore consume the same immutable layout through one lifecycle. self._clone_plan: ClonePlan | None = None + self._clone_plan_consumed: bool = False # Default visualization dt used before/without visualizer initialization. physics_dt = getattr(self.cfg.physics, "dt", None) self._viz_dt = (physics_dt if physics_dt is not None else self.cfg.dt) * self.cfg.render_interval @@ -686,15 +686,30 @@ def register_interactive_scene(self, scene) -> None: def get_clone_plan(self) -> ClonePlan | None: """Return the clone plan published by the scene. - Set after replication. Consumed by scene data providers that build backend models - (e.g. Newton visualizer model on a PhysX backend) from the same plan the cloner used. - ``None`` until the scene replicates. + Set before cfg-owned scene construction and retained through backend replication. + ``None`` until a clone lifecycle begins. """ return self._clone_plan def set_clone_plan(self, plan: ClonePlan | None) -> None: - """Set the cloner's clone plan.""" + """Publish or clear this simulation's single clone plan. + + Raises: + RuntimeError: If another plan is active or the current plan was consumed. + """ + if self._clone_plan_consumed: + raise RuntimeError("A consumed clone lifecycle cannot be cleared or replaced.") + if plan is self._clone_plan: + return + if plan is not None and self._clone_plan is not None: + raise RuntimeError("A SimulationContext owns exactly one clone lifecycle.") self._clone_plan = plan + self._clone_plan_consumed = False + + def _consume_clone_plan(self, plan: ClonePlan) -> None: + """Publish ``plan`` and atomically claim its single backend dispatch.""" + self.set_clone_plan(plan) + self._clone_plan_consumed = True @property def visualizers(self) -> list[BaseVisualizer]: diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index 54d5e93e09a3..a5c844d62b67 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -64,6 +64,29 @@ class Explicit(_Context): cfg.cloning_contexts = (Explicit,) assert make_clone_plan((cfg,), 2, 1.0).context_rows == {Explicit: (0,)} + empty = make_clone_plan((), 2, 1.0, global_paths=("/World/Ground",)) + assert empty.context_rows == {_Context: ()} + assert empty.global_paths == ("/World/Ground",) + + +def test_queue_accepts_only_cfgs_owned_by_published_plan(monkeypatch): + """Cfg-first constructors cannot escape the published plan.""" + planned = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=object()) + unplanned = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object", spawn=object()) + reference = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Existing", spawn=None) + plan = _plan() + plan.cfg_rows[id(planned)] = (0,) + simulation = SimpleNamespace(get_clone_plan=lambda: plan, _clone_plan_consumed=False) + replicate_session.REPLICATION_QUEUE.clear() + monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) + + replicate_session.queue_replication(planned) + simulation._clone_plan_consumed = True + replicate_session.queue_replication(reference) + with pytest.raises(RuntimeError, match="not owned"): + replicate_session.queue_replication(unplanned) + + assert replicate_session.REPLICATION_QUEUE == [] @pytest.mark.parametrize("valid_set", [np.asarray([["0"]]), np.asarray([[0 + 1j]])]) @@ -98,13 +121,18 @@ class Early(_Context): simulation = SimpleNamespace( physics_manager=SimpleNamespace(clone_context_type=Late), _backend_registry={Late: Late(calls), Early: Early(calls)}, - set_clone_plan=lambda value: calls.append(value), + _clone_plan=None, + _clone_plan_consumed=False, ) + simulation.get_clone_plan = lambda: simulation._clone_plan + simulation.set_clone_plan = lambda value: SimulationContext.set_clone_plan(simulation, value) + simulation._consume_clone_plan = lambda value: SimulationContext._consume_clone_plan(simulation, value) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) replicate_session.replicate(plan) - assert calls == [(Early, plan), (Late, plan), plan] + assert calls == [(Early, plan), (Late, plan)] + assert simulation._clone_plan is plan def test_replicate_physics_false_runs_only_usd(monkeypatch): @@ -121,8 +149,12 @@ class Usd(_Context): simulation = SimpleNamespace( physics_manager=SimpleNamespace(clone_context_type=Physics), _backend_registry={UsdReplicateContext: Usd(calls)}, - set_clone_plan=lambda value: None, + _clone_plan=plan, + get_clone_plan=lambda: plan, + _clone_plan_consumed=False, ) + simulation.set_clone_plan = lambda value: SimulationContext.set_clone_plan(simulation, value) + simulation._consume_clone_plan = lambda value: SimulationContext._consume_clone_plan(simulation, value) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) replicate_session.replicate(plan, replicate_physics=False) @@ -136,9 +168,30 @@ def test_replicate_rejects_unregistered_context(monkeypatch): simulation = SimpleNamespace( physics_manager=SimpleNamespace(clone_context_type=_Context), _backend_registry={}, - set_clone_plan=lambda _: None, + _clone_plan=plan, + get_clone_plan=lambda: plan, + _clone_plan_consumed=False, ) + simulation.set_clone_plan = lambda value: SimulationContext.set_clone_plan(simulation, value) + simulation._consume_clone_plan = lambda value: SimulationContext._consume_clone_plan(simulation, value) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) with pytest.raises(RuntimeError, match="must be registered"): replicate_session.replicate(plan) + assert simulation._clone_plan_consumed is True + + +def test_replicate_rejects_a_second_dispatch(monkeypatch): + """One published plan has exactly one backend dispatch.""" + plan = _plan() + simulation = SimpleNamespace(_clone_plan=plan, get_clone_plan=lambda: plan, _clone_plan_consumed=True) + simulation.set_clone_plan = lambda value: SimulationContext.set_clone_plan(simulation, value) + simulation._consume_clone_plan = lambda value: SimulationContext._consume_clone_plan(simulation, value) + monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) + replicate_session.REPLICATION_QUEUE.append(object()) + + with pytest.raises(RuntimeError, match="consumed"): + replicate_session.replicate(plan) + assert replicate_session.REPLICATION_QUEUE == [] + with pytest.raises(RuntimeError, match="consumed"): + simulation.set_clone_plan(None) diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 1e4012376bd0..73af560a85ca 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -12,13 +12,13 @@ """Rest everything follows.""" -from dataclasses import replace from types import SimpleNamespace import pytest import torch import isaaclab.sim as sim_utils +from isaaclab import cloner from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg, RigidObjectCollectionCfg from isaaclab.cloner import CloneCfg @@ -183,8 +183,8 @@ def test_reset_to_env_ids_input_types(device, setup_scene): assert_state_equal(prev_state, scene.get_state()) -def test_scene_publishes_plan_via_replicate(monkeypatch: pytest.MonkeyPatch): - """A cfg-driven scene forwards its plan and tracks the latest published layout. +def test_scene_publishes_plan_before_replicate(monkeypatch: pytest.MonkeyPatch): + """A cfg-driven scene publishes the exact plan it forwards to replication. Uses a test-seam fake to isolate this unit test from real backend dispatch; queue lifecycle is owned by :func:`replicate` itself (snapshot-and-clear) and does not @@ -195,25 +195,33 @@ def test_scene_publishes_plan_via_replicate(monkeypatch: pytest.MonkeyPatch): captured: list = [] def fake_replicate(plan, *, replicate_physics=True): - captured.append((plan, replicate_physics)) + captured.append((plan, replicate_physics, sim_utils.SimulationContext.instance().get_clone_plan())) monkeypatch.setattr(replicate_session_module, "replicate", fake_replicate) with build_simulation_context(device="cpu", auto_add_lighting=False, add_ground_plane=False) as sim: sim._app_control_on_stop_handle = None - scene = InteractiveScene(MySceneCfg(num_envs=4, env_spacing=1.0)) - replacement = replace(captured[0][0], positions=captured[0][0].positions + 1.0) - sim.set_clone_plan(replacement) - torch.testing.assert_close(scene.env_origins, torch.from_numpy(replacement.positions)) + InteractiveScene(MySceneCfg(num_envs=4, env_spacing=1.0)) assert len(captured) == 1 - plan, replicate_physics = captured[0] + plan, replicate_physics, published = captured[0] + assert published is plan assert plan.sources == ("/World/envs/env_0",) assert plan.destinations == ("/World/envs/env_{}",) assert plan.clone_mask.shape == (1, 4) assert replicate_physics is True +def test_empty_scene_leaves_clone_lifecycle_to_caller(): + """An empty scene authors usable env roots without claiming the direct task's plan.""" + with build_simulation_context(device="cpu", auto_add_lighting=False, add_ground_plane=False) as sim: + sim._app_control_on_stop_handle = None + scene = InteractiveScene(InteractiveSceneCfg(num_envs=4, env_spacing=1.0)) + + assert sim.get_clone_plan() is None + torch.testing.assert_close(scene.env_origins, torch.from_numpy(cloner.grid_transforms(4, 1.0)[0])) + + @pytest.mark.parametrize("device", ["cuda:0"]) @pytest.mark.parametrize("replicate_physics", [True, False]) def test_replicate_physics_flag_controls_physx_replicator(device, replicate_physics, setup_scene, monkeypatch): diff --git a/source/isaaclab/test/sensors/generate_synthetic_gaussian_asset.py b/source/isaaclab/test/sensors/generate_synthetic_gaussian_asset.py index 56e4fd06b09e..4e02663ad0ae 100644 --- a/source/isaaclab/test/sensors/generate_synthetic_gaussian_asset.py +++ b/source/isaaclab/test/sensors/generate_synthetic_gaussian_asset.py @@ -29,7 +29,6 @@ from pxr import Gf, Sdf, Usd, UsdGeom, Vt import isaaclab.sim as sim_utils -from isaaclab import cloner from isaaclab.assets import AssetBaseCfg, RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors.camera import Camera, CameraCfg @@ -734,14 +733,6 @@ def render_synthetic_gaussian_scene( renderer_cfg=renderer_cfg, ) camera = Camera(cfg) - # Camera is constructed after the scene's ReplicateSession has exited, so its - # queued USD replication needs an explicit drain (Path B). Reuse the scene's - # env positions so env_origins stays consistent. - published = sim.get_clone_plan() - positions = published.positions if published is not None else None - src, dst = "/World/envs/env_0", "/World/envs/env_{}" - camera_plan = cloner.clone_plan_from_env_0(src, dst, num_envs, positions) - cloner.replicate(camera_plan) sim.reset() for _ in range(stabilisation_steps): sim.step() diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index a82053c60d9b..e674a8611110 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -478,10 +478,12 @@ def test_replicate_session_clears_queue_when_asset_init_fails(sim): cfgs=[], num_clones=2, env_spacing=1.0, - ): + ) as session: + assert sim.get_clone_plan() is session.plan leaked_cfg.cloning_contexts = (sentinel_cls,) REPLICATION_QUEUE.append(leaked_cfg) raise RuntimeError("asset boom") assert REPLICATION_QUEUE == [] + assert sim.get_clone_plan() is None sentinel_cls.assert_not_called() From 0c30b918939a6e46fc8587a879b4ddd087943083 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 4 Sep 2026 00:13:59 -0700 Subject: [PATCH 2/8] Tighten clone plan ownership --- source/isaaclab/isaaclab/cloner/replicate_session.py | 11 ++++------- source/isaaclab/isaaclab/scene/interactive_scene.py | 7 ++++++- source/isaaclab/test/cloner/test_replicate_session.py | 4 ++-- source/isaaclab/test/scene/test_interactive_scene.py | 4 ++++ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index 2d2bd9d2f654..d911a80b55d1 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -18,7 +18,6 @@ from .cloner_cfg import DEFAULT_ENV_TEMPLATE from .cloner_strategies import sequential from .path import under -from .query import path_to_source from .usd import UsdReplicateContext if TYPE_CHECKING: @@ -45,14 +44,12 @@ def queue_replication(cfg: Any) -> None: REPLICATION_QUEUE.append(cfg) return - prim_path = getattr(cfg, "prim_path", None) - global_owned = isinstance(prim_path, str) and any(under(prim_path, root) for root in plan.global_paths) - row_owned = isinstance(prim_path, str) and path_to_source(plan, prim_path) is not None - if not sim._clone_plan_consumed and (id(cfg) in plan.cfg_rows or global_owned or row_owned): + if cfg.spawn is None: return - if getattr(cfg, "spawn", None) is None and (global_owned or row_owned): + global_owned = any(under(cfg.prim_path, root) for root in plan.global_paths) + if not sim._clone_plan_consumed and (id(cfg) in plan.cfg_rows or global_owned): return - raise RuntimeError(f"{type(cfg).__name__} at {prim_path!r} is not owned by the active ClonePlan.") + raise RuntimeError(f"{type(cfg).__name__} at {cfg.prim_path!r} is not owned by the active ClonePlan.") def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 014371edffd8..55650f4904e8 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -371,7 +371,11 @@ def env_origins(self) -> torch.Tensor: if self._terrain is not None: return self._terrain.env_origins plan = self.sim.get_clone_plan() - return torch.as_tensor(plan.positions, device=self.device) if plan is not None else self._env_origins + if plan is not self._env_origins_plan: + self._env_origins_plan = plan + if plan is not None and plan.positions is not None: + self._env_origins = torch.as_tensor(plan.positions, device=self.device) + return self._env_origins @property def terrain(self) -> TerrainImporter | None: @@ -779,6 +783,7 @@ def _author_envs(self, env_ids: np.ndarray, positions: np.ndarray) -> None: """Author environment roots from the active layout.""" self._ALL_INDICES = torch.as_tensor(env_ids, device=self.device) self._env_origins = torch.as_tensor(positions, device=self.device) + self._env_origins_plan = self.sim.get_clone_plan() self.stage.DefinePrim(self.env_prim_paths[0], "Xform") with cloner.disabled_fabric_change_notifies(self.stage, restore=False): cloner.usd_replicate(self.stage, [self.env_prim_paths[0]], [self._env_fmt], env_ids, positions=positions) diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index a5c844d62b67..f981a59f0f05 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -81,10 +81,10 @@ def test_queue_accepts_only_cfgs_owned_by_published_plan(monkeypatch): monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) replicate_session.queue_replication(planned) - simulation._clone_plan_consumed = True - replicate_session.queue_replication(reference) with pytest.raises(RuntimeError, match="not owned"): replicate_session.queue_replication(unplanned) + simulation._clone_plan_consumed = True + replicate_session.queue_replication(reference) assert replicate_session.REPLICATION_QUEUE == [] diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 73af560a85ca..3a449828fdd2 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -220,6 +220,10 @@ def test_empty_scene_leaves_clone_lifecycle_to_caller(): assert sim.get_clone_plan() is None torch.testing.assert_close(scene.env_origins, torch.from_numpy(cloner.grid_transforms(4, 1.0)[0])) + positions = cloner.grid_transforms(4, 2.0)[0] + env_template = scene.cfg.clone_cfg.clone_template + sim.set_clone_plan(cloner.clone_plan_from_env_0(env_template.format(0), env_template, 4, positions)) + torch.testing.assert_close(scene.env_origins, torch.from_numpy(positions)) @pytest.mark.parametrize("device", ["cuda:0"]) From 99ac29c665f2a516e64a8f227add5da990a82751 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 4 Sep 2026 00:15:20 -0700 Subject: [PATCH 3/8] Restrict late clone views --- source/isaaclab/isaaclab/cloner/replicate_session.py | 5 +++-- source/isaaclab/test/cloner/test_replicate_session.py | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index d911a80b55d1..bfb75a9c0016 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -18,6 +18,7 @@ from .cloner_cfg import DEFAULT_ENV_TEMPLATE from .cloner_strategies import sequential from .path import under +from .query import path_to_source from .usd import UsdReplicateContext if TYPE_CHECKING: @@ -44,11 +45,11 @@ def queue_replication(cfg: Any) -> None: REPLICATION_QUEUE.append(cfg) return - if cfg.spawn is None: - return global_owned = any(under(cfg.prim_path, root) for root in plan.global_paths) if not sim._clone_plan_consumed and (id(cfg) in plan.cfg_rows or global_owned): return + if cfg.spawn is None and (global_owned or path_to_source(plan, cfg.prim_path) is not None): + return raise RuntimeError(f"{type(cfg).__name__} at {cfg.prim_path!r} is not owned by the active ClonePlan.") diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index f981a59f0f05..daa42d1291ba 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -73,7 +73,8 @@ def test_queue_accepts_only_cfgs_owned_by_published_plan(monkeypatch): """Cfg-first constructors cannot escape the published plan.""" planned = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=object()) unplanned = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object", spawn=object()) - reference = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Existing", spawn=None) + covered_reference = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Existing", spawn=None) + unplanned_reference = SimpleNamespace(prim_path="/World/Outside", spawn=None) plan = _plan() plan.cfg_rows[id(planned)] = (0,) simulation = SimpleNamespace(get_clone_plan=lambda: plan, _clone_plan_consumed=False) @@ -84,7 +85,9 @@ def test_queue_accepts_only_cfgs_owned_by_published_plan(monkeypatch): with pytest.raises(RuntimeError, match="not owned"): replicate_session.queue_replication(unplanned) simulation._clone_plan_consumed = True - replicate_session.queue_replication(reference) + replicate_session.queue_replication(covered_reference) + with pytest.raises(RuntimeError, match="not owned"): + replicate_session.queue_replication(unplanned_reference) assert replicate_session.REPLICATION_QUEUE == [] From 2be5fa0b4eed2f1cd1fee909467a81893c3f1055 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 4 Sep 2026 00:16:45 -0700 Subject: [PATCH 4/8] Validate clone dispatch before consuming --- source/isaaclab/isaaclab/cloner/replicate_session.py | 3 +-- source/isaaclab/test/cloner/test_replicate_session.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index bfb75a9c0016..a60e7f98c72e 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -70,8 +70,6 @@ def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: sim = SimulationContext.instance() if sim is None: raise RuntimeError("Clone-plan replication requires an active SimulationContext.") - sim._consume_clone_plan(plan) - context_types = tuple( context_type for context_type in plan.context_rows if replicate_physics or context_type is UsdReplicateContext ) @@ -81,6 +79,7 @@ def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: raise RuntimeError(f"Clone contexts must be registered before plan dispatch: {names}.") contexts = [sim._backend_registry[context_type] for context_type in context_types] + sim._consume_clone_plan(plan) for context in sorted(contexts, key=lambda item: item.replicate_priority): context.replicate(plan) diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index daa42d1291ba..3358b90d35ae 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -181,7 +181,7 @@ def test_replicate_rejects_unregistered_context(monkeypatch): with pytest.raises(RuntimeError, match="must be registered"): replicate_session.replicate(plan) - assert simulation._clone_plan_consumed is True + assert simulation._clone_plan_consumed is False def test_replicate_rejects_a_second_dispatch(monkeypatch): From 2c199ea85dc70f0dc3a70cdfd0a98e6f1dcec82b Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 4 Sep 2026 04:57:57 -0700 Subject: [PATCH 5/8] Clarify clone plan inspection --- docs/source/how-to/cloning.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index 1faf46f5ce53..ad82c4478c6c 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -216,7 +216,7 @@ them is purely about ergonomics: when you want the lifecycle hidden and you are authoring assets through a scene config. * The second spells the same flow out as plain function calls, leaving a moment - between the build and the drain where you can inspect or mutate the plan. + between the build and the drain where you can inspect the plan. Reach for it when you are assembling a scene outside :class:`~isaaclab.scene.InteractiveScene` or want fine control over timing. * The third is a one-shot shortcut for the case where every env is just a copy From eca3cd27c6cd0af1f7166e6239ba78e93804336b Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 4 Sep 2026 15:52:34 -0700 Subject: [PATCH 6/8] Simplify clone lifecycle ownership --- docs/source/how-to/cloning.rst | 35 +++------- .../ooctipus-single-clone-lifecycle.major.rst | 8 +-- source/isaaclab/isaaclab/cloner/clone_plan.py | 18 ++---- .../isaaclab/cloner/replicate_session.py | 28 +++----- .../isaaclab/scene/interactive_scene.py | 62 +++++++++--------- .../isaaclab/sim/simulation_context.py | 19 +----- .../test/cloner/test_replicate_session.py | 64 ++++--------------- .../test/scene/test_interactive_scene.py | 27 ++++++-- 8 files changed, 95 insertions(+), 166 deletions(-) diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index ad82c4478c6c..9353bbde25d5 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -207,7 +207,7 @@ need every variant behind a template. Note that environment ids are not mask col column ``j`` stands for ``env_ids[j]``, and the queries speak ids throughout. A plan is the *what*. Putting one together and handing it to the backends is -the *how*, and Isaac Lab exposes three idiomatic ways to do that. All three end +the *how*, and Isaac Lab exposes two idiomatic ways to do that. Both end in the same ``cloner.replicate(plan)`` call, so the choice between them is purely about ergonomics: @@ -215,11 +215,7 @@ them is purely about ergonomics: :class:`~isaaclab.scene.InteractiveScene` runs under the hood. Reach for it when you want the lifecycle hidden and you are authoring assets through a scene config. -* The second spells the same flow out as plain function calls, leaving a moment - between the build and the drain where you can inspect the plan. - Reach for it when you are assembling a scene outside - :class:`~isaaclab.scene.InteractiveScene` or want fine control over timing. -* The third is a one-shot shortcut for the case where every env is just a copy +* The second is a one-shot shortcut for the case where every env is just a copy of env_0. Reach for it in :class:`~isaaclab.envs.DirectRLEnv` and standalone scripts that hand-build the env-0 prototype prim by prim. @@ -256,21 +252,6 @@ When envs need to differ across the population, use :class:`~isaaclab.sim.spawners.wrappers.MultiUsdFileCfg`; see :doc:`multi_asset_spawning`. -``make_clone_plan`` + ``replicate`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The same lifecycle as the session, written as separate function calls. Publish the -plan before construction so every participant observes the same layout, then -dispatch it after the prototypes exist: - -.. code-block:: python - - plan = cloner.make_clone_plan(cfgs, num_clones=N, env_spacing=2.0) - sim.set_clone_plan(plan) - for cfg in cfgs: - cfg.class_type(cfg) - cloner.replicate(plan) - ``clone_plan_from_env_0`` + ``replicate`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -294,9 +275,10 @@ subclasses use — they author the env-0 prototype prim by prim in plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths) cloner.replicate(plan) -Every env receives the same prototype. When envs need to differ, use one of the -other two. Hand-built scenes must pass every shared asset root in ``global_paths``; -use ``()`` when there are none. +Every env receives the same prototype. When envs need to differ, declare their +assets on :class:`~isaaclab.scene.InteractiveSceneCfg` so the scene owns the +session-backed lifecycle. Hand-built scenes must pass every shared asset root in +``global_paths``; use ``()`` when there are none. Under the Hood @@ -325,15 +307,14 @@ execution contract: .. code-block:: python - simulation.set_clone_plan(plan) - construct_prototypes() + plan = published_clone_plan for context_type in plan.context_rows: simulation_backends[context_type].replicate(plan) The cfg-first lifecycle publishes before ``construct_prototypes()``. The direct single-source workflow remains post-construction and is published by :func:`~isaaclab.cloner.replicate` immediately before dispatch. In either form, -the simulation accepts one plan and each backend receives that exact object once. +each maintained lifecycle passes that exact object to every backend. USD runs before native physics contexts so the destination topology exists when they consume it. No fallback context is constructed during dispatch. diff --git a/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst b/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst index 21b6159b59cc..e4dd338b5a06 100644 --- a/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst +++ b/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst @@ -1,7 +1,7 @@ Changed ^^^^^^^ -* **Breaking:** Published cfg-owned clone plans before scene construction and limited each - :class:`~isaaclab.sim.SimulationContext` to one plan and one dispatch. Custom scene composition - roots should build and publish one plan before constructing its participants, then pass that same - plan once to :func:`~isaaclab.cloner.replicate`. +* **Breaking:** Published :class:`~isaaclab.cloner.ReplicateSession` plans on entry and dispatched + them on exit. Empty :class:`~isaaclab.scene.InteractiveScene` configurations now author only + ``env_0``; direct workflows must finish setup with :func:`~isaaclab.cloner.clone_plan_from_env_0` + and :func:`~isaaclab.cloner.replicate`. diff --git a/source/isaaclab/isaaclab/cloner/clone_plan.py b/source/isaaclab/isaaclab/cloner/clone_plan.py index 8632c40e0186..e49815048c04 100644 --- a/source/isaaclab/isaaclab/cloner/clone_plan.py +++ b/source/isaaclab/isaaclab/cloner/clone_plan.py @@ -203,10 +203,7 @@ def make_valid_clone_combinations( def _context_rows( - cfgs: tuple[Any, ...], - cfg_rows: dict[int, tuple[int, ...]], - populated_rows: set[int], - global_paths: tuple[str, ...] = (), + cfgs: tuple[Any, ...], cfg_rows: dict[int, tuple[int, ...]], populated_rows: set[int] ) -> dict[type[object], tuple[int, ...]]: """Route plan rows to the clone contexts registered for this simulation.""" sim = sim_utils.SimulationContext.instance() @@ -216,9 +213,7 @@ def _context_rows( physics_context = sim.physics_manager.clone_context_type if physics_context is not None and not isinstance(physics_context, type): raise TypeError("PhysicsManager.clone_context_type must be a context class.") - rows_by_context: dict[type[object], set[int]] = ( - {} if physics_context is None or not global_paths else {physics_context: set()} - ) + rows_by_context: dict[type[object], set[int]] = {} for cfg in cfgs: rows = cfg_rows.get(id(cfg)) @@ -241,7 +236,7 @@ def _context_rows( return { context_type: tuple(sorted(rows & populated_rows)) for context_type, rows in rows_by_context.items() - if rows & populated_rows or context_type is physics_context and bool(global_paths) + if rows & populated_rows } @@ -318,7 +313,6 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: env_ids=env_ids, positions=positions, cfg_rows={}, - context_rows=_context_rows(cfgs, {}, set(), global_paths), global_paths=global_paths, ) @@ -335,7 +329,7 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None: env_ids=env_ids, positions=positions, cfg_rows=cfg_rows, - context_rows=_context_rows(cfgs, cfg_rows, {0}, global_paths), + context_rows=_context_rows(cfgs, cfg_rows, {0}), global_paths=global_paths, ) @@ -408,7 +402,7 @@ def validate_combinations(combos: np.ndarray, name: str, expected_rows: int | No env_ids=env_ids, positions=positions, cfg_rows=cfg_rows, - context_rows=_context_rows(cfgs, cfg_rows, populated_rows, global_paths), + context_rows=_context_rows(cfgs, cfg_rows, populated_rows), global_paths=global_paths, ) @@ -451,6 +445,6 @@ def clone_plan_from_env_0( env_ids=np.arange(num_clones, dtype=np.int64), positions=positions, cfg_rows=cfg_rows, - context_rows=_context_rows(queued, cfg_rows, {0}, global_paths), + context_rows=_context_rows(queued, cfg_rows, {0}), global_paths=global_paths, ) diff --git a/source/isaaclab/isaaclab/cloner/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index a60e7f98c72e..7181f1fccd72 100644 --- a/source/isaaclab/isaaclab/cloner/replicate_session.py +++ b/source/isaaclab/isaaclab/cloner/replicate_session.py @@ -17,8 +17,6 @@ from .clone_plan import make_clone_plan from .cloner_cfg import DEFAULT_ENV_TEMPLATE from .cloner_strategies import sequential -from .path import under -from .query import path_to_source from .usd import UsdReplicateContext if TYPE_CHECKING: @@ -34,23 +32,13 @@ def queue_replication(cfg: Any) -> None: - """Register a constructed cfg or verify that the active plan owns it. + """Register a constructed cfg when no clone plan is active. Args: cfg: Asset cfg with resolved ``prim_path``. """ - sim = SimulationContext.instance() - plan = None if sim is None else sim.get_clone_plan() - if plan is None: + if (sim := SimulationContext.instance()) is None or sim.get_clone_plan() is None: REPLICATION_QUEUE.append(cfg) - return - - global_owned = any(under(cfg.prim_path, root) for root in plan.global_paths) - if not sim._clone_plan_consumed and (id(cfg) in plan.cfg_rows or global_owned): - return - if cfg.spawn is None and (global_owned or path_to_source(plan, cfg.prim_path) is not None): - return - raise RuntimeError(f"{type(cfg).__name__} at {cfg.prim_path!r} is not owned by the active ClonePlan.") def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: @@ -78,8 +66,12 @@ def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None: names = ", ".join(f"{context_type.__module__}.{context_type.__qualname__}" for context_type in missing) raise RuntimeError(f"Clone contexts must be registered before plan dispatch: {names}.") + if (active_plan := sim.get_clone_plan()) is None: + sim.set_clone_plan(plan) + elif active_plan is not plan: + raise ValueError("replicate() requires the active SimulationContext's ClonePlan.") + contexts = [sim._backend_registry[context_type] for context_type in context_types] - sim._consume_clone_plan(plan) for context in sorted(contexts, key=lambda item: item.replicate_priority): context.replicate(plan) @@ -138,8 +130,7 @@ def __init__( self._plan: ClonePlan | None = None def __enter__(self) -> ReplicateSession: - sim = SimulationContext.instance() - if sim is None: + if (sim := SimulationContext.instance()) is None: raise RuntimeError("Clone planning requires an active SimulationContext.") if sim.get_clone_plan() is not None: raise RuntimeError("A SimulationContext owns exactly one clone lifecycle.") @@ -154,8 +145,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: else: # Drop cfgs registered before the failure so the next session is clean. REPLICATION_QUEUE.clear() - sim = SimulationContext.instance() - if sim is not None and sim.get_clone_plan() is self._plan: + if (sim := SimulationContext.instance()) is not None and sim.get_clone_plan() is self._plan: sim.set_clone_plan(None) @property diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 55650f4904e8..b7bbc1d0309d 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -168,29 +168,41 @@ def __init__(self, cfg: InteractiveSceneCfg): # the template is authoritative; the regex form is the same namespace spelled for matching self._env_fmt = self.cloner_cfg.clone_template self.env_prim_paths = [self._env_fmt.format(i) for i in range(self.cfg.num_envs)] - self._scene_asset_names: list[str] = [] - self._clone_valid_set: np.ndarray | None = None + self._ALL_INDICES = torch.arange(self.cfg.num_envs, dtype=torch.long, device=self.device) self._global_prim_paths = list() - clone_cfgs, global_paths = self._collect_asset_cfgs() - scene_from_cfg = bool(self._scene_asset_names) + asset_cfgs, global_paths, valid_set = self._collect_asset_cfgs() + scene_from_cfg = any( + name not in InteractiveSceneCfg.__dataclass_fields__ and cfg is not None + for name, cfg in self.cfg.__dict__.items() + ) if scene_from_cfg: with cloner.ReplicateSession( - clone_cfgs, + asset_cfgs, num_clones=self.num_envs, env_spacing=self.cfg.env_spacing, global_paths=global_paths, env_template=self._env_fmt, clone_strategy=self.cloner_cfg.clone_strategy, - valid_set=self._clone_valid_set, + valid_set=valid_set, replicate_physics=self.cloner_cfg.replicate_physics, ) as session: - self._author_envs(session.plan.env_ids, session.plan.positions) + self.stage.DefinePrim(self.env_prim_paths[0], "Xform") + with cloner.disabled_fabric_change_notifies(self.stage, restore=False): + cloner.usd_replicate( + self.stage, + [self.env_prim_paths[0]], + [self._env_fmt], + session.plan.env_ids, + positions=session.plan.positions, + ) self._add_entities_from_cfg() + positions = session.plan.positions else: - env_ids = np.arange(self.num_envs, dtype=np.int64) - env_origins = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing)[0] - self._author_envs(env_ids, env_origins) + self.stage.DefinePrim(self.env_prim_paths[0], "Xform") + positions = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing)[0] + self._env_origins = torch.as_tensor(positions, device=self.device) + self._env_origins_plan = self.sim.get_clone_plan() # Every sensor exists by now, so all visualizer and camera-renderer requirements are visible. cam_types = [s.cfg.renderer_cfg.renderer_type for s in self._sensors.values() if isinstance(s.cfg, CameraCfg)] @@ -203,17 +215,17 @@ def __init__(self, cfg: InteractiveSceneCfg): if self.cfg.filter_collisions and "physx" in self.physics_backend and scene_from_cfg: self.filter_collisions(self._global_prim_paths) - def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]: + def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...], np.ndarray | None]: """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 returns only env-scoped configs with a spawner. Global roots are returned separately. + and returns env-scoped configs with a spawner, global roots, and valid clone combinations. """ cfg_fields = InteractiveSceneCfg.__dataclass_fields__ items = [(name, cfg) for name, cfg in self.cfg.__dict__.items() if name not in cfg_fields and cfg is not None] - self._scene_asset_names = [name for name, _ in items] + scene_asset_names = [name for name, _ in items] flat_items: list[tuple[str, Any]] = [] for asset_name, asset_cfg in items: children = ( @@ -239,7 +251,7 @@ def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]: and any(cloner.path.relative_to(cfg.prim_path, owner) not in (None, "") for owner in owner_paths) } nested_material_names = {name for name, cfg in flat_items if id(cfg) in nested_visual_material_ids} - self._scene_asset_names = [name for name in self._scene_asset_names if name not in nested_material_names] + scene_asset_names = [name for name in scene_asset_names if name not in nested_material_names] cfgs: list[Any] = [] global_paths: tuple[str, ...] = () @@ -259,15 +271,15 @@ def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]: variant_counts.append(cloner.num_spawn_variants(child.spawn)) if self.cloner_cfg.clone_combinations and clone_asset_names: - self._clone_valid_set = cloner.make_valid_clone_combinations( + valid_set = cloner.make_valid_clone_combinations( clone_asset_names, variant_counts, self.cloner_cfg.clone_combinations, - all_asset_names=self._scene_asset_names, + all_asset_names=scene_asset_names, ) else: - self._clone_valid_set = None - return cfgs, global_paths + valid_set = None + return cfgs, global_paths, valid_set def filter_collisions(self, global_prim_paths: list[str] | None = None): """Filter environments collisions. @@ -371,10 +383,9 @@ def env_origins(self) -> torch.Tensor: if self._terrain is not None: return self._terrain.env_origins plan = self.sim.get_clone_plan() - if plan is not self._env_origins_plan: + if plan is not None and plan is not self._env_origins_plan: + self._env_origins = torch.as_tensor(plan.positions, device=self.device) self._env_origins_plan = plan - if plan is not None and plan.positions is not None: - self._env_origins = torch.as_tensor(plan.positions, device=self.device) return self._env_origins @property @@ -779,15 +790,6 @@ def __getitem__(self, key: str) -> Any: Internal methods. """ - def _author_envs(self, env_ids: np.ndarray, positions: np.ndarray) -> None: - """Author environment roots from the active layout.""" - self._ALL_INDICES = torch.as_tensor(env_ids, device=self.device) - self._env_origins = torch.as_tensor(positions, device=self.device) - self._env_origins_plan = self.sim.get_clone_plan() - self.stage.DefinePrim(self.env_prim_paths[0], "Xform") - with cloner.disabled_fabric_change_notifies(self.stage, restore=False): - cloner.usd_replicate(self.stage, [self.env_prim_paths[0]], [self._env_fmt], env_ids, positions=positions) - def _add_entities_from_cfg(self): # noqa: C901 """Add scene entities from the config.""" from isaaclab_physx.assets import SurfaceGripperCfg # noqa: PLC0415 diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 05152f77f758..b162c725a801 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -196,7 +196,6 @@ def __init__(self, cfg: SimulationCfg | None = None): # Clone plan published before cfg-owned scene construction. Constructors and # backends therefore consume the same immutable layout through one lifecycle. self._clone_plan: ClonePlan | None = None - self._clone_plan_consumed: bool = False # Default visualization dt used before/without visualizer initialization. physics_dt = getattr(self.cfg.physics, "dt", None) self._viz_dt = (physics_dt if physics_dt is not None else self.cfg.dt) * self.cfg.render_interval @@ -692,24 +691,8 @@ def get_clone_plan(self) -> ClonePlan | None: return self._clone_plan def set_clone_plan(self, plan: ClonePlan | None) -> None: - """Publish or clear this simulation's single clone plan. - - Raises: - RuntimeError: If another plan is active or the current plan was consumed. - """ - if self._clone_plan_consumed: - raise RuntimeError("A consumed clone lifecycle cannot be cleared or replaced.") - if plan is self._clone_plan: - return - if plan is not None and self._clone_plan is not None: - raise RuntimeError("A SimulationContext owns exactly one clone lifecycle.") + """Set the cloner's active clone plan.""" self._clone_plan = plan - self._clone_plan_consumed = False - - def _consume_clone_plan(self, plan: ClonePlan) -> None: - """Publish ``plan`` and atomically claim its single backend dispatch.""" - self.set_clone_plan(plan) - self._clone_plan_consumed = True @property def visualizers(self) -> list[BaseVisualizer]: diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index 3358b90d35ae..75cb28936f8d 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -64,32 +64,23 @@ class Explicit(_Context): cfg.cloning_contexts = (Explicit,) assert make_clone_plan((cfg,), 2, 1.0).context_rows == {Explicit: (0,)} - empty = make_clone_plan((), 2, 1.0, global_paths=("/World/Ground",)) - assert empty.context_rows == {_Context: ()} - assert empty.global_paths == ("/World/Ground",) -def test_queue_accepts_only_cfgs_owned_by_published_plan(monkeypatch): - """Cfg-first constructors cannot escape the published plan.""" - planned = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Robot", spawn=object()) - unplanned = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Object", spawn=object()) - covered_reference = SimpleNamespace(prim_path="/World/envs/env_[^/]+/Existing", spawn=None) - unplanned_reference = SimpleNamespace(prim_path="/World/Outside", spawn=None) +def test_queue_collects_only_before_plan_publication(monkeypatch): + """Post-construction planning ignores cfgs built after a plan is active.""" + cfg = object() plan = _plan() - plan.cfg_rows[id(planned)] = (0,) - simulation = SimpleNamespace(get_clone_plan=lambda: plan, _clone_plan_consumed=False) + published = None + simulation = SimpleNamespace(get_clone_plan=lambda: published) replicate_session.REPLICATION_QUEUE.clear() monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) - replicate_session.queue_replication(planned) - with pytest.raises(RuntimeError, match="not owned"): - replicate_session.queue_replication(unplanned) - simulation._clone_plan_consumed = True - replicate_session.queue_replication(covered_reference) - with pytest.raises(RuntimeError, match="not owned"): - replicate_session.queue_replication(unplanned_reference) + replicate_session.queue_replication(cfg) + assert [cfg] == replicate_session.REPLICATION_QUEUE - assert replicate_session.REPLICATION_QUEUE == [] + published = plan + replicate_session.queue_replication(object()) + assert [cfg] == replicate_session.REPLICATION_QUEUE @pytest.mark.parametrize("valid_set", [np.asarray([["0"]]), np.asarray([[0 + 1j]])]) @@ -121,21 +112,19 @@ class Early(_Context): replicate_priority = -1 plan = _plan(Late, Early) + published = [] simulation = SimpleNamespace( physics_manager=SimpleNamespace(clone_context_type=Late), _backend_registry={Late: Late(calls), Early: Early(calls)}, - _clone_plan=None, - _clone_plan_consumed=False, + get_clone_plan=lambda: None, + set_clone_plan=published.append, ) - simulation.get_clone_plan = lambda: simulation._clone_plan - simulation.set_clone_plan = lambda value: SimulationContext.set_clone_plan(simulation, value) - simulation._consume_clone_plan = lambda value: SimulationContext._consume_clone_plan(simulation, value) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) replicate_session.replicate(plan) assert calls == [(Early, plan), (Late, plan)] - assert simulation._clone_plan is plan + assert published == [plan] def test_replicate_physics_false_runs_only_usd(monkeypatch): @@ -152,12 +141,8 @@ class Usd(_Context): simulation = SimpleNamespace( physics_manager=SimpleNamespace(clone_context_type=Physics), _backend_registry={UsdReplicateContext: Usd(calls)}, - _clone_plan=plan, get_clone_plan=lambda: plan, - _clone_plan_consumed=False, ) - simulation.set_clone_plan = lambda value: SimulationContext.set_clone_plan(simulation, value) - simulation._consume_clone_plan = lambda value: SimulationContext._consume_clone_plan(simulation, value) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) replicate_session.replicate(plan, replicate_physics=False) @@ -171,30 +156,9 @@ def test_replicate_rejects_unregistered_context(monkeypatch): simulation = SimpleNamespace( physics_manager=SimpleNamespace(clone_context_type=_Context), _backend_registry={}, - _clone_plan=plan, get_clone_plan=lambda: plan, - _clone_plan_consumed=False, ) - simulation.set_clone_plan = lambda value: SimulationContext.set_clone_plan(simulation, value) - simulation._consume_clone_plan = lambda value: SimulationContext._consume_clone_plan(simulation, value) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) with pytest.raises(RuntimeError, match="must be registered"): replicate_session.replicate(plan) - assert simulation._clone_plan_consumed is False - - -def test_replicate_rejects_a_second_dispatch(monkeypatch): - """One published plan has exactly one backend dispatch.""" - plan = _plan() - simulation = SimpleNamespace(_clone_plan=plan, get_clone_plan=lambda: plan, _clone_plan_consumed=True) - simulation.set_clone_plan = lambda value: SimulationContext.set_clone_plan(simulation, value) - simulation._consume_clone_plan = lambda value: SimulationContext._consume_clone_plan(simulation, value) - monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) - replicate_session.REPLICATION_QUEUE.append(object()) - - with pytest.raises(RuntimeError, match="consumed"): - replicate_session.replicate(plan) - assert replicate_session.REPLICATION_QUEUE == [] - with pytest.raises(RuntimeError, match="consumed"): - simulation.set_clone_plan(None) diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 3a449828fdd2..23f163b65efc 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -14,6 +14,7 @@ from types import SimpleNamespace +import numpy as np import pytest import torch @@ -213,16 +214,30 @@ def fake_replicate(plan, *, replicate_physics=True): def test_empty_scene_leaves_clone_lifecycle_to_caller(): - """An empty scene authors usable env roots without claiming the direct task's plan.""" + """An empty scene authors one prototype and leaves its replication to the direct task.""" with build_simulation_context(device="cpu", auto_add_lighting=False, add_ground_plane=False) as sim: sim._app_control_on_stop_handle = None scene = InteractiveScene(InteractiveSceneCfg(num_envs=4, env_spacing=1.0)) assert sim.get_clone_plan() is None - torch.testing.assert_close(scene.env_origins, torch.from_numpy(cloner.grid_transforms(4, 1.0)[0])) - positions = cloner.grid_transforms(4, 2.0)[0] env_template = scene.cfg.clone_cfg.clone_template - sim.set_clone_plan(cloner.clone_plan_from_env_0(env_template.format(0), env_template, 4, positions)) + grid_positions = cloner.grid_transforms(4, 1.0)[0] + torch.testing.assert_close(scene.env_origins, torch.from_numpy(grid_positions)) + assert scene.stage.GetPrimAtPath(env_template.format(0)).IsValid() + assert all(not scene.stage.GetPrimAtPath(env_template.format(i)).IsValid() for i in range(1, 4)) + + cube_cfg = RigidObjectCfg( + prim_path=f"{env_template.format('[^/]+')}/Cube", + spawn=sim_utils.CuboidCfg(size=(0.1, 0.1, 0.1)), + cloning_contexts=(cloner.UsdReplicateContext,), + ) + cube_cfg.class_type(cube_cfg) + positions = grid_positions + np.asarray((0.25, 0.5, 0.75), dtype=np.float32) + plan = cloner.clone_plan_from_env_0(env_template.format(0), env_template, 4, positions) + cloner.replicate(plan) + + assert sim.get_clone_plan() is plan + assert all(scene.stage.GetPrimAtPath(f"{env_template.format(i)}/Cube").IsValid() for i in range(4)) torch.testing.assert_close(scene.env_origins, torch.from_numpy(positions)) @@ -297,7 +312,7 @@ def test_collect_asset_cfgs_resolves_env_regex_macros_and_declares_globals(): scene.cloner_cfg = CloneCfg() scene._env_fmt = scene.cloner_cfg.clone_template - cfgs, global_paths = 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"] @@ -313,7 +328,7 @@ def test_collect_asset_cfgs_excludes_entities_without_spawners(): scene.cloner_cfg = CloneCfg() scene._env_fmt = scene.cloner_cfg.clone_template - cfgs, global_paths = scene._collect_asset_cfgs() + cfgs, global_paths, _ = scene._collect_asset_cfgs() assert cfgs == [] assert global_paths == () From 579bd62a44eda5bd18aaa911bf225be1f852734b Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 4 Sep 2026 20:50:58 -0700 Subject: [PATCH 7/8] Defer environment origin tensor creation --- source/isaaclab/isaaclab/scene/interactive_scene.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index b7bbc1d0309d..84f8a2384c5e 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -201,7 +201,7 @@ def __init__(self, cfg: InteractiveSceneCfg): else: self.stage.DefinePrim(self.env_prim_paths[0], "Xform") positions = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing)[0] - self._env_origins = torch.as_tensor(positions, device=self.device) + self._env_origins = positions self._env_origins_plan = self.sim.get_clone_plan() # Every sensor exists by now, so all visualizer and camera-renderer requirements are visible. @@ -384,8 +384,10 @@ def env_origins(self) -> torch.Tensor: return self._terrain.env_origins plan = self.sim.get_clone_plan() if plan is not None and plan is not self._env_origins_plan: - self._env_origins = torch.as_tensor(plan.positions, device=self.device) + self._env_origins = plan.positions self._env_origins_plan = plan + if not isinstance(self._env_origins, torch.Tensor): + self._env_origins = torch.as_tensor(self._env_origins, device=self.device) return self._env_origins @property From 6907aaf19d6978563bc2395fc45ef532fa1757d2 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Fri, 4 Sep 2026 20:51:04 -0700 Subject: [PATCH 8/8] Use clone plan for shadow visualization layout --- .../sim/test_newton_manager_visualization_state.py | 7 ++++--- .../ooctipus-plan-owned-shadow-layout.rst | 4 ++++ .../isaaclab_newton/physics/newton_manager.py | 2 +- .../isaaclab_newton/physics/visualization_builder.py | 12 +++++------- .../test/cloner/test_rename_builder_labels.py | 4 +++- 5 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/ooctipus-plan-owned-shadow-layout.rst diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index de22f2028bc2..eda98596db56 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -52,7 +52,7 @@ def _make_standalone_stage(): def _set_sim_context(monkeypatch, nm, clone_plan=_DEFAULT, scene_data_provider=_DEFAULT): - clone_plan = SimpleNamespace() if clone_plan is _DEFAULT else clone_plan + clone_plan = SimpleNamespace(clone_mask=np.ones((1, 1), dtype=np.bool_)) if clone_plan is _DEFAULT else clone_plan scene_data_provider = SimpleNamespace() if scene_data_provider is _DEFAULT else scene_data_provider sim = SimpleNamespace( get_clone_plan=lambda: clone_plan, @@ -418,9 +418,9 @@ def test_ensure_visualization_model_populates_num_envs_when_backend_is_physx(mon _reset_newton_manager_state() monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: False)) - monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: _make_env_stage(num_envs=4)) + monkeypatch.setattr(nm, "get_current_stage", lambda *args, **kwargs: _make_env_stage()) monkeypatch.setattr(nm.PhysicsManager, "_sim", None, raising=False) - _set_sim_context(monkeypatch, nm) + _set_sim_context(monkeypatch, nm, clone_plan=SimpleNamespace(clone_mask=np.ones((1, 4), dtype=np.bool_))) monkeypatch.setattr(nm.PhysicsManager, "_device", "cpu", raising=False) builder = _make_finalize_builder(body_count=3) @@ -835,6 +835,7 @@ def test_clone_visualization_builder_ignores_non_env_deformables_on_world_import destinations=("/World/envs/env_{}",), env_ids=np.asarray([0, 1], dtype=np.int64), clone_mask=np.asarray([[False, False]], dtype=np.bool_), + positions=np.zeros((2, 3), dtype=np.float32), ) monkeypatch.setattr(vb, "ModelBuilder", lambda up_axis="Z": fake_builder) monkeypatch.setattr(vb, "_restore_visible_colliders_without_visual_shapes", lambda *args, **kwargs: None) diff --git a/source/isaaclab_newton/changelog.d/ooctipus-plan-owned-shadow-layout.rst b/source/isaaclab_newton/changelog.d/ooctipus-plan-owned-shadow-layout.rst new file mode 100644 index 000000000000..28f10cea43bf --- /dev/null +++ b/source/isaaclab_newton/changelog.d/ooctipus-plan-owned-shadow-layout.rst @@ -0,0 +1,4 @@ +Fixed +^^^^^ + +* Built Newton shadow visualization layouts from clone-plan positions when destination USD environment prims are absent. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 873592097d87..e637a1daa9da 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -2902,7 +2902,7 @@ def _ensure_visualization_model(cls) -> None: "deferring visualization model creation." ) return - NewtonManager._num_envs = len(env_paths) if clone_plan is not None else 1 + NewtonManager._num_envs = clone_plan.clone_mask.shape[1] if clone_plan is not None else 1 builder, (shadow_entities, registry_groups) = build_visualization_builder_from_stage_envs( stage, env_paths, clone_plan, up_axis=up_axis, device=str(PhysicsManager._device or "cpu") ) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py b/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py index c44d657b2ee1..9c12ad784ac2 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/visualization_builder.py @@ -15,7 +15,6 @@ from pxr import Usd from isaaclab.scene_data.deformable_discovery import DeformableStageEntry, discover_deformables_on_stage -from isaaclab.sim.utils.transforms import resolve_prim_pose from isaaclab_newton.cloner.newton_clone_utils import ( _restore_visible_colliders_without_visual_shapes, @@ -120,16 +119,15 @@ def build_visualization_builder_from_stage_envs( if not env_paths: raise ValueError("clone plan requires at least one environment path") - env_path_by_id = dict(env_paths) - sources = tuple(clone_plan.sources) destinations = tuple(clone_plan.destinations) env_ids = clone_plan.env_ids mapping = clone_plan.clone_mask - - poses = [resolve_prim_pose(stage.GetPrimAtPath(env_path_by_id[int(env_id)])) for env_id in env_ids] - positions = np.asarray([pos for pos, _ in poses], dtype=np.float32) - quaternions = np.asarray([quat for _, quat in poses], dtype=np.float32) + if env_ids is None or clone_plan.positions is None: + raise ValueError("clone plan requires environment ids and positions for visualization") + positions = clone_plan.positions.astype(np.float32, copy=False) + quaternions = np.zeros((len(env_ids), 4), dtype=np.float32) + quaternions[:, 3] = 1.0 # Ignore every deformable on the stage for the world import — not only those under # clone sources. Otherwise a non-env deformable (e.g. ``/World/Assets/Cloth``) is # imported here and added again by ``add_shadow_deformables_to_builder``. 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 0d9c84703bbc..12fd45f2f518 100644 --- a/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py +++ b/source/isaaclab_newton/test/cloner/test_rename_builder_labels.py @@ -414,6 +414,7 @@ def test_visualization_builder_disables_collision_pairs(self): destinations=("/World/envs/env_{}/Robot",), clone_mask=np.ones((1, 2), dtype=np.bool_), env_ids=np.arange(2, dtype=np.int64), + positions=np.asarray(((0.0, 0.0, 0.0), (2.0, 0.0, 0.0)), dtype=np.float32), ) for env_paths, plan, expected_shape_count in ( ([], None, 2), @@ -451,7 +452,7 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) self._define_xform(stage, "/World") self._define_xform(stage, "/World/envs") - env_paths = [(env_id, f"/World/envs/env_{env_id}") for env_id in (0, 1, 2)] + env_paths = [(env_id, f"/World/envs/env_{env_id}") for env_id in (0, 1)] for env_id, env_path in env_paths: self._define_xform(stage, env_path, (float(env_id) * 3.0, 0.0, 0.0)) self._define_xform(stage, f"{env_path}/Object") @@ -463,6 +464,7 @@ def test_visualization_builder_uses_clone_plan_sources_and_rewrites_labels(self) destinations=("/World/envs/env_{}/Object", "/World/envs/env_{}/Object"), clone_mask=np.array([[True, False, True], [False, True, False]], dtype=np.bool_), env_ids=np.array([0, 1, 2], dtype=np.int64), + positions=np.asarray(((0.0, 0.0, 0.0), (3.0, 0.0, 0.0), (6.0, 0.0, 0.0)), dtype=np.float32), ) with (