Skip to content

Commit eca3cd2

Browse files
committed
Simplify clone lifecycle ownership
1 parent 2c199ea commit eca3cd2

8 files changed

Lines changed: 95 additions & 166 deletions

File tree

docs/source/how-to/cloning.rst

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -207,19 +207,15 @@ need every variant behind a template. Note that environment ids are not mask col
207207
column ``j`` stands for ``env_ids[j]``, and the queries speak ids throughout.
208208

209209
A plan is the *what*. Putting one together and handing it to the backends is
210-
the *how*, and Isaac Lab exposes three idiomatic ways to do that. All three end
210+
the *how*, and Isaac Lab exposes two idiomatic ways to do that. Both end
211211
in the same ``cloner.replicate(plan)`` call, so the choice between
212212
them is purely about ergonomics:
213213

214214
* The first wraps both phases in a context manager and is what
215215
:class:`~isaaclab.scene.InteractiveScene` runs under the hood. Reach for it
216216
when you want the lifecycle hidden and you are authoring assets through a
217217
scene config.
218-
* The second spells the same flow out as plain function calls, leaving a moment
219-
between the build and the drain where you can inspect the plan.
220-
Reach for it when you are assembling a scene outside
221-
:class:`~isaaclab.scene.InteractiveScene` or want fine control over timing.
222-
* The third is a one-shot shortcut for the case where every env is just a copy
218+
* The second is a one-shot shortcut for the case where every env is just a copy
223219
of env_0. Reach for it in :class:`~isaaclab.envs.DirectRLEnv` and standalone
224220
scripts that hand-build the env-0 prototype prim by prim.
225221

@@ -256,21 +252,6 @@ When envs need to differ across the population, use
256252
:class:`~isaaclab.sim.spawners.wrappers.MultiUsdFileCfg`; see
257253
:doc:`multi_asset_spawning`.
258254

259-
``make_clone_plan`` + ``replicate``
260-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
261-
262-
The same lifecycle as the session, written as separate function calls. Publish the
263-
plan before construction so every participant observes the same layout, then
264-
dispatch it after the prototypes exist:
265-
266-
.. code-block:: python
267-
268-
plan = cloner.make_clone_plan(cfgs, num_clones=N, env_spacing=2.0)
269-
sim.set_clone_plan(plan)
270-
for cfg in cfgs:
271-
cfg.class_type(cfg)
272-
cloner.replicate(plan)
273-
274255
``clone_plan_from_env_0`` + ``replicate``
275256
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
276257

@@ -294,9 +275,10 @@ subclasses use — they author the env-0 prototype prim by prim in
294275
plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos, global_paths=global_paths)
295276
cloner.replicate(plan)
296277
297-
Every env receives the same prototype. When envs need to differ, use one of the
298-
other two. Hand-built scenes must pass every shared asset root in ``global_paths``;
299-
use ``()`` when there are none.
278+
Every env receives the same prototype. When envs need to differ, declare their
279+
assets on :class:`~isaaclab.scene.InteractiveSceneCfg` so the scene owns the
280+
session-backed lifecycle. Hand-built scenes must pass every shared asset root in
281+
``global_paths``; use ``()`` when there are none.
300282

301283

302284
Under the Hood
@@ -325,15 +307,14 @@ execution contract:
325307

326308
.. code-block:: python
327309
328-
simulation.set_clone_plan(plan)
329-
construct_prototypes()
310+
plan = published_clone_plan
330311
for context_type in plan.context_rows:
331312
simulation_backends[context_type].replicate(plan)
332313
333314
The cfg-first lifecycle publishes before ``construct_prototypes()``. The direct
334315
single-source workflow remains post-construction and is published by
335316
:func:`~isaaclab.cloner.replicate` immediately before dispatch. In either form,
336-
the simulation accepts one plan and each backend receives that exact object once.
317+
each maintained lifecycle passes that exact object to every backend.
337318

338319
USD runs before native physics contexts so the destination topology exists when
339320
they consume it. No fallback context is constructed during dispatch.
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Changed
22
^^^^^^^
33

4-
* **Breaking:** Published cfg-owned clone plans before scene construction and limited each
5-
:class:`~isaaclab.sim.SimulationContext` to one plan and one dispatch. Custom scene composition
6-
roots should build and publish one plan before constructing its participants, then pass that same
7-
plan once to :func:`~isaaclab.cloner.replicate`.
4+
* **Breaking:** Published :class:`~isaaclab.cloner.ReplicateSession` plans on entry and dispatched
5+
them on exit. Empty :class:`~isaaclab.scene.InteractiveScene` configurations now author only
6+
``env_0``; direct workflows must finish setup with :func:`~isaaclab.cloner.clone_plan_from_env_0`
7+
and :func:`~isaaclab.cloner.replicate`.

source/isaaclab/isaaclab/cloner/clone_plan.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,7 @@ def make_valid_clone_combinations(
203203

204204

205205
def _context_rows(
206-
cfgs: tuple[Any, ...],
207-
cfg_rows: dict[int, tuple[int, ...]],
208-
populated_rows: set[int],
209-
global_paths: tuple[str, ...] = (),
206+
cfgs: tuple[Any, ...], cfg_rows: dict[int, tuple[int, ...]], populated_rows: set[int]
210207
) -> dict[type[object], tuple[int, ...]]:
211208
"""Route plan rows to the clone contexts registered for this simulation."""
212209
sim = sim_utils.SimulationContext.instance()
@@ -216,9 +213,7 @@ def _context_rows(
216213
physics_context = sim.physics_manager.clone_context_type
217214
if physics_context is not None and not isinstance(physics_context, type):
218215
raise TypeError("PhysicsManager.clone_context_type must be a context class.")
219-
rows_by_context: dict[type[object], set[int]] = (
220-
{} if physics_context is None or not global_paths else {physics_context: set()}
221-
)
216+
rows_by_context: dict[type[object], set[int]] = {}
222217

223218
for cfg in cfgs:
224219
rows = cfg_rows.get(id(cfg))
@@ -241,7 +236,7 @@ def _context_rows(
241236
return {
242237
context_type: tuple(sorted(rows & populated_rows))
243238
for context_type, rows in rows_by_context.items()
244-
if rows & populated_rows or context_type is physics_context and bool(global_paths)
239+
if rows & populated_rows
245240
}
246241

247242

@@ -318,7 +313,6 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None:
318313
env_ids=env_ids,
319314
positions=positions,
320315
cfg_rows={},
321-
context_rows=_context_rows(cfgs, {}, set(), global_paths),
322316
global_paths=global_paths,
323317
)
324318

@@ -335,7 +329,7 @@ def set_spawn_paths(spawn_cfg: Any, paths: list[str | None]) -> None:
335329
env_ids=env_ids,
336330
positions=positions,
337331
cfg_rows=cfg_rows,
338-
context_rows=_context_rows(cfgs, cfg_rows, {0}, global_paths),
332+
context_rows=_context_rows(cfgs, cfg_rows, {0}),
339333
global_paths=global_paths,
340334
)
341335

@@ -408,7 +402,7 @@ def validate_combinations(combos: np.ndarray, name: str, expected_rows: int | No
408402
env_ids=env_ids,
409403
positions=positions,
410404
cfg_rows=cfg_rows,
411-
context_rows=_context_rows(cfgs, cfg_rows, populated_rows, global_paths),
405+
context_rows=_context_rows(cfgs, cfg_rows, populated_rows),
412406
global_paths=global_paths,
413407
)
414408

@@ -451,6 +445,6 @@ def clone_plan_from_env_0(
451445
env_ids=np.arange(num_clones, dtype=np.int64),
452446
positions=positions,
453447
cfg_rows=cfg_rows,
454-
context_rows=_context_rows(queued, cfg_rows, {0}, global_paths),
448+
context_rows=_context_rows(queued, cfg_rows, {0}),
455449
global_paths=global_paths,
456450
)

source/isaaclab/isaaclab/cloner/replicate_session.py

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
from .clone_plan import make_clone_plan
1818
from .cloner_cfg import DEFAULT_ENV_TEMPLATE
1919
from .cloner_strategies import sequential
20-
from .path import under
21-
from .query import path_to_source
2220
from .usd import UsdReplicateContext
2321

2422
if TYPE_CHECKING:
@@ -34,23 +32,13 @@
3432

3533

3634
def queue_replication(cfg: Any) -> None:
37-
"""Register a constructed cfg or verify that the active plan owns it.
35+
"""Register a constructed cfg when no clone plan is active.
3836
3937
Args:
4038
cfg: Asset cfg with resolved ``prim_path``.
4139
"""
42-
sim = SimulationContext.instance()
43-
plan = None if sim is None else sim.get_clone_plan()
44-
if plan is None:
40+
if (sim := SimulationContext.instance()) is None or sim.get_clone_plan() is None:
4541
REPLICATION_QUEUE.append(cfg)
46-
return
47-
48-
global_owned = any(under(cfg.prim_path, root) for root in plan.global_paths)
49-
if not sim._clone_plan_consumed and (id(cfg) in plan.cfg_rows or global_owned):
50-
return
51-
if cfg.spawn is None and (global_owned or path_to_source(plan, cfg.prim_path) is not None):
52-
return
53-
raise RuntimeError(f"{type(cfg).__name__} at {cfg.prim_path!r} is not owned by the active ClonePlan.")
5442

5543

5644
def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None:
@@ -78,8 +66,12 @@ def replicate(plan: ClonePlan, *, replicate_physics: bool = True) -> None:
7866
names = ", ".join(f"{context_type.__module__}.{context_type.__qualname__}" for context_type in missing)
7967
raise RuntimeError(f"Clone contexts must be registered before plan dispatch: {names}.")
8068

69+
if (active_plan := sim.get_clone_plan()) is None:
70+
sim.set_clone_plan(plan)
71+
elif active_plan is not plan:
72+
raise ValueError("replicate() requires the active SimulationContext's ClonePlan.")
73+
8174
contexts = [sim._backend_registry[context_type] for context_type in context_types]
82-
sim._consume_clone_plan(plan)
8375
for context in sorted(contexts, key=lambda item: item.replicate_priority):
8476
context.replicate(plan)
8577

@@ -138,8 +130,7 @@ def __init__(
138130
self._plan: ClonePlan | None = None
139131

140132
def __enter__(self) -> ReplicateSession:
141-
sim = SimulationContext.instance()
142-
if sim is None:
133+
if (sim := SimulationContext.instance()) is None:
143134
raise RuntimeError("Clone planning requires an active SimulationContext.")
144135
if sim.get_clone_plan() is not None:
145136
raise RuntimeError("A SimulationContext owns exactly one clone lifecycle.")
@@ -154,8 +145,7 @@ def __exit__(self, exc_type, exc_value, traceback) -> None:
154145
else:
155146
# Drop cfgs registered before the failure so the next session is clean.
156147
REPLICATION_QUEUE.clear()
157-
sim = SimulationContext.instance()
158-
if sim is not None and sim.get_clone_plan() is self._plan:
148+
if (sim := SimulationContext.instance()) is not None and sim.get_clone_plan() is self._plan:
159149
sim.set_clone_plan(None)
160150

161151
@property

source/isaaclab/isaaclab/scene/interactive_scene.py

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -168,29 +168,41 @@ def __init__(self, cfg: InteractiveSceneCfg):
168168
# the template is authoritative; the regex form is the same namespace spelled for matching
169169
self._env_fmt = self.cloner_cfg.clone_template
170170
self.env_prim_paths = [self._env_fmt.format(i) for i in range(self.cfg.num_envs)]
171-
self._scene_asset_names: list[str] = []
172-
self._clone_valid_set: np.ndarray | None = None
171+
self._ALL_INDICES = torch.arange(self.cfg.num_envs, dtype=torch.long, device=self.device)
173172

174173
self._global_prim_paths = list()
175-
clone_cfgs, global_paths = self._collect_asset_cfgs()
176-
scene_from_cfg = bool(self._scene_asset_names)
174+
asset_cfgs, global_paths, valid_set = self._collect_asset_cfgs()
175+
scene_from_cfg = any(
176+
name not in InteractiveSceneCfg.__dataclass_fields__ and cfg is not None
177+
for name, cfg in self.cfg.__dict__.items()
178+
)
177179
if scene_from_cfg:
178180
with cloner.ReplicateSession(
179-
clone_cfgs,
181+
asset_cfgs,
180182
num_clones=self.num_envs,
181183
env_spacing=self.cfg.env_spacing,
182184
global_paths=global_paths,
183185
env_template=self._env_fmt,
184186
clone_strategy=self.cloner_cfg.clone_strategy,
185-
valid_set=self._clone_valid_set,
187+
valid_set=valid_set,
186188
replicate_physics=self.cloner_cfg.replicate_physics,
187189
) as session:
188-
self._author_envs(session.plan.env_ids, session.plan.positions)
190+
self.stage.DefinePrim(self.env_prim_paths[0], "Xform")
191+
with cloner.disabled_fabric_change_notifies(self.stage, restore=False):
192+
cloner.usd_replicate(
193+
self.stage,
194+
[self.env_prim_paths[0]],
195+
[self._env_fmt],
196+
session.plan.env_ids,
197+
positions=session.plan.positions,
198+
)
189199
self._add_entities_from_cfg()
200+
positions = session.plan.positions
190201
else:
191-
env_ids = np.arange(self.num_envs, dtype=np.int64)
192-
env_origins = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing)[0]
193-
self._author_envs(env_ids, env_origins)
202+
self.stage.DefinePrim(self.env_prim_paths[0], "Xform")
203+
positions = cloner.grid_transforms(self.num_envs, self.cfg.env_spacing)[0]
204+
self._env_origins = torch.as_tensor(positions, device=self.device)
205+
self._env_origins_plan = self.sim.get_clone_plan()
194206

195207
# Every sensor exists by now, so all visualizer and camera-renderer requirements are visible.
196208
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):
203215
if self.cfg.filter_collisions and "physx" in self.physics_backend and scene_from_cfg:
204216
self.filter_collisions(self._global_prim_paths)
205217

206-
def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]:
218+
def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...], np.ndarray | None]:
207219
"""Flatten user-declared cfgs and declare shared prim roots for clone planning.
208220
209221
Expands :class:`~isaaclab.assets.RigidObjectCollectionCfg` into its members,
210222
resolves ``{ENV_REGEX_NS}`` macros, lets an enclosing asset's row own nested materials,
211-
and returns only env-scoped configs with a spawner. Global roots are returned separately.
223+
and returns env-scoped configs with a spawner, global roots, and valid clone combinations.
212224
"""
213225

214226
cfg_fields = InteractiveSceneCfg.__dataclass_fields__
215227
items = [(name, cfg) for name, cfg in self.cfg.__dict__.items() if name not in cfg_fields and cfg is not None]
216-
self._scene_asset_names = [name for name, _ in items]
228+
scene_asset_names = [name for name, _ in items]
217229
flat_items: list[tuple[str, Any]] = []
218230
for asset_name, asset_cfg in items:
219231
children = (
@@ -239,7 +251,7 @@ def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]:
239251
and any(cloner.path.relative_to(cfg.prim_path, owner) not in (None, "") for owner in owner_paths)
240252
}
241253
nested_material_names = {name for name, cfg in flat_items if id(cfg) in nested_visual_material_ids}
242-
self._scene_asset_names = [name for name in self._scene_asset_names if name not in nested_material_names]
254+
scene_asset_names = [name for name in scene_asset_names if name not in nested_material_names]
243255

244256
cfgs: list[Any] = []
245257
global_paths: tuple[str, ...] = ()
@@ -259,15 +271,15 @@ def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[str, ...]]:
259271
variant_counts.append(cloner.num_spawn_variants(child.spawn))
260272

261273
if self.cloner_cfg.clone_combinations and clone_asset_names:
262-
self._clone_valid_set = cloner.make_valid_clone_combinations(
274+
valid_set = cloner.make_valid_clone_combinations(
263275
clone_asset_names,
264276
variant_counts,
265277
self.cloner_cfg.clone_combinations,
266-
all_asset_names=self._scene_asset_names,
278+
all_asset_names=scene_asset_names,
267279
)
268280
else:
269-
self._clone_valid_set = None
270-
return cfgs, global_paths
281+
valid_set = None
282+
return cfgs, global_paths, valid_set
271283

272284
def filter_collisions(self, global_prim_paths: list[str] | None = None):
273285
"""Filter environments collisions.
@@ -371,10 +383,9 @@ def env_origins(self) -> torch.Tensor:
371383
if self._terrain is not None:
372384
return self._terrain.env_origins
373385
plan = self.sim.get_clone_plan()
374-
if plan is not self._env_origins_plan:
386+
if plan is not None and plan is not self._env_origins_plan:
387+
self._env_origins = torch.as_tensor(plan.positions, device=self.device)
375388
self._env_origins_plan = plan
376-
if plan is not None and plan.positions is not None:
377-
self._env_origins = torch.as_tensor(plan.positions, device=self.device)
378389
return self._env_origins
379390

380391
@property
@@ -779,15 +790,6 @@ def __getitem__(self, key: str) -> Any:
779790
Internal methods.
780791
"""
781792

782-
def _author_envs(self, env_ids: np.ndarray, positions: np.ndarray) -> None:
783-
"""Author environment roots from the active layout."""
784-
self._ALL_INDICES = torch.as_tensor(env_ids, device=self.device)
785-
self._env_origins = torch.as_tensor(positions, device=self.device)
786-
self._env_origins_plan = self.sim.get_clone_plan()
787-
self.stage.DefinePrim(self.env_prim_paths[0], "Xform")
788-
with cloner.disabled_fabric_change_notifies(self.stage, restore=False):
789-
cloner.usd_replicate(self.stage, [self.env_prim_paths[0]], [self._env_fmt], env_ids, positions=positions)
790-
791793
def _add_entities_from_cfg(self): # noqa: C901
792794
"""Add scene entities from the config."""
793795
from isaaclab_physx.assets import SurfaceGripperCfg # noqa: PLC0415

source/isaaclab/isaaclab/sim/simulation_context.py

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,6 @@ def __init__(self, cfg: SimulationCfg | None = None):
196196
# Clone plan published before cfg-owned scene construction. Constructors and
197197
# backends therefore consume the same immutable layout through one lifecycle.
198198
self._clone_plan: ClonePlan | None = None
199-
self._clone_plan_consumed: bool = False
200199
# Default visualization dt used before/without visualizer initialization.
201200
physics_dt = getattr(self.cfg.physics, "dt", None)
202201
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:
692691
return self._clone_plan
693692

694693
def set_clone_plan(self, plan: ClonePlan | None) -> None:
695-
"""Publish or clear this simulation's single clone plan.
696-
697-
Raises:
698-
RuntimeError: If another plan is active or the current plan was consumed.
699-
"""
700-
if self._clone_plan_consumed:
701-
raise RuntimeError("A consumed clone lifecycle cannot be cleared or replaced.")
702-
if plan is self._clone_plan:
703-
return
704-
if plan is not None and self._clone_plan is not None:
705-
raise RuntimeError("A SimulationContext owns exactly one clone lifecycle.")
694+
"""Set the cloner's active clone plan."""
706695
self._clone_plan = plan
707-
self._clone_plan_consumed = False
708-
709-
def _consume_clone_plan(self, plan: ClonePlan) -> None:
710-
"""Publish ``plan`` and atomically claim its single backend dispatch."""
711-
self.set_clone_plan(plan)
712-
self._clone_plan_consumed = True
713696

714697
@property
715698
def visualizers(self) -> list[BaseVisualizer]:

0 commit comments

Comments
 (0)