diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index 84dd34ca1269..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 or mutate 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. @@ -227,9 +223,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 @@ -257,22 +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 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: - -.. code-block:: python - - plan = cloner.make_clone_plan(cfgs, num_clones=N, env_spacing=2.0) - for cfg in cfgs: - cfg.class_type(cfg) - cloner.replicate(plan) - ``clone_plan_from_env_0`` + ``replicate`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -296,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 @@ -323,18 +303,21 @@ 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) + 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, +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. 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..e4dd338b5a06 --- /dev/null +++ b/source/isaaclab/changelog.d/ooctipus-single-clone-lifecycle.major.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* **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/replicate_session.py b/source/isaaclab/isaaclab/cloner/replicate_session.py index f92bbd0f42ee..7181f1fccd72 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 @@ -32,16 +32,17 @@ def queue_replication(cfg: Any) -> None: - """Register a constructed cfg for post-construction clone planning. + """Register a constructed cfg when no clone plan is active. Args: cfg: Asset cfg with resolved ``prim_path``. """ - REPLICATION_QUEUE.append(cfg) + if (sim := SimulationContext.instance()) is None or sim.get_clone_plan() is None: + REPLICATION_QUEUE.append(cfg) 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 @@ -65,17 +66,21 @@ 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] 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 +130,12 @@ def __init__( self._plan: ClonePlan | None = None def __enter__(self) -> ReplicateSession: + 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.") 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 +145,8 @@ 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() + if (sim := SimulationContext.instance()) 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..84f8a2384c5e 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -168,37 +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) - # 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(): + 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( + 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=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, + ) self._add_entities_from_cfg() - self._env_origins_plan = session.plan - self._env_origins = torch.as_tensor(session.plan.positions, device=self.device) + positions = session.plan.positions + 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 = positions + 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)] @@ -208,20 +212,20 @@ 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, ...]]: + 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 = ( @@ -247,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, ...] = () @@ -267,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. @@ -375,17 +379,15 @@ 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) + plan = self.sim.get_clone_plan() + if plan is not None and plan is not self._env_origins_plan: + 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 @@ -440,11 +442,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,17 +792,6 @@ 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 _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 37ab8bb62850..b162c725a801 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -193,9 +193,8 @@ 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 # Default visualization dt used before/without visualizer initialization. physics_dt = getattr(self.cfg.physics, "dt", None) @@ -686,14 +685,13 @@ 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.""" + """Set the cloner's active clone plan.""" self._clone_plan = plan @property diff --git a/source/isaaclab/test/cloner/test_replicate_session.py b/source/isaaclab/test/cloner/test_replicate_session.py index 54d5e93e09a3..75cb28936f8d 100644 --- a/source/isaaclab/test/cloner/test_replicate_session.py +++ b/source/isaaclab/test/cloner/test_replicate_session.py @@ -66,6 +66,23 @@ class Explicit(_Context): assert make_clone_plan((cfg,), 2, 1.0).context_rows == {Explicit: (0,)} +def test_queue_collects_only_before_plan_publication(monkeypatch): + """Post-construction planning ignores cfgs built after a plan is active.""" + cfg = object() + plan = _plan() + published = None + simulation = SimpleNamespace(get_clone_plan=lambda: published) + replicate_session.REPLICATION_QUEUE.clear() + monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) + + replicate_session.queue_replication(cfg) + assert [cfg] == 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]])]) def test_make_clone_plan_rejects_non_integer_combinations(valid_set): """Prototype indices must be integer data rather than values NumPy can coerce to integers.""" @@ -95,16 +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)}, - set_clone_plan=lambda value: calls.append(value), + get_clone_plan=lambda: None, + set_clone_plan=published.append, ) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) replicate_session.replicate(plan) - assert calls == [(Early, plan), (Late, plan), plan] + assert calls == [(Early, plan), (Late, plan)] + assert published == [plan] def test_replicate_physics_false_runs_only_usd(monkeypatch): @@ -121,7 +141,7 @@ class Usd(_Context): simulation = SimpleNamespace( physics_manager=SimpleNamespace(clone_context_type=Physics), _backend_registry={UsdReplicateContext: Usd(calls)}, - set_clone_plan=lambda value: None, + get_clone_plan=lambda: plan, ) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) @@ -136,7 +156,7 @@ def test_replicate_rejects_unregistered_context(monkeypatch): simulation = SimpleNamespace( physics_manager=SimpleNamespace(clone_context_type=_Context), _backend_registry={}, - set_clone_plan=lambda _: None, + get_clone_plan=lambda: plan, ) monkeypatch.setattr(SimulationContext, "instance", lambda: simulation) diff --git a/source/isaaclab/test/scene/test_interactive_scene.py b/source/isaaclab/test/scene/test_interactive_scene.py index 1e4012376bd0..23f163b65efc 100644 --- a/source/isaaclab/test/scene/test_interactive_scene.py +++ b/source/isaaclab/test/scene/test_interactive_scene.py @@ -12,13 +12,14 @@ """Rest everything follows.""" -from dataclasses import replace from types import SimpleNamespace +import numpy as np 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 +184,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 +196,51 @@ 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 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 + env_template = scene.cfg.clone_cfg.clone_template + 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)) + + @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): @@ -285,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"] @@ -301,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 == () 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() 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 (