Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 18 additions & 35 deletions docs/source/how-to/cloning.rst
Original file line number Diff line number Diff line change
Expand Up @@ -207,29 +207,24 @@ 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:

* The first wraps both phases in a context manager and is what
: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.

``ReplicateSession``
~~~~~~~~~~~~~~~~~~~~

: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

Expand Down Expand Up @@ -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``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand All @@ -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
Expand All @@ -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
-------------------
Expand Down
Original file line number Diff line number Diff line change
@@ -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`.
26 changes: 19 additions & 7 deletions source/isaaclab/isaaclab/cloner/replicate_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
102 changes: 46 additions & 56 deletions source/isaaclab/isaaclab/scene/interactive_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = torch.as_tensor(positions, device=self.device)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need _env_origins to be torch tensor? Ideally we want to keep initialization python or numpy array only

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 579bd62. InteractiveScene now keeps the plan positions as NumPy during construction and materializes/caches the device tensor only when the public env_origins property is first read. Publishing a later direct-workflow plan replaces that cache with the new NumPy positions, so there is still exactly one conversion per plan.

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)]
Expand All @@ -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 = (
Expand All @@ -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, ...] = ()
Expand All @@ -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.
Expand Down Expand Up @@ -375,15 +379,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:
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 = plan
return self._env_origins
Expand Down Expand Up @@ -440,11 +440,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()

Expand Down Expand Up @@ -789,17 +790,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
Expand Down
12 changes: 5 additions & 7 deletions source/isaaclab/isaaclab/sim/simulation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading