Skip to content

Commit 6dedbb7

Browse files
Refactor cloning around cfg-driven ClonePlan (#5528)
# Description This PR a lot simplify cloner logic: Refactors scene cloning so `InteractiveScene` builds a `ClonePlan` directly from asset configuration, rewrites spawner configs to spawn representative sources in their selected environment paths, and then replicates directly from those sources to the remaining destinations. This removes the previous template round trip and hard-deletes `clone_from_template`. This also updates the cloner API around `CloneCfg` and `make_clone_plan`, adds explicit `spawn_paths` support for multi-asset spawners, tightens rigid object collection spawning invariants, and refreshes docs, tests, and changelog coverage for the new planning flow. Fixes # N/A Dependencies: none. ## Type of change - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots N/A. ## Test plan Focused tests were run individually while developing this branch: - `source/isaaclab/test/scene/test_interactive_scene.py` - `source/isaaclab/test/sim/test_cloner.py` - `source/isaaclab/test/sim/test_spawn_wrappers.py` - `source/isaaclab_physx/test/sim/test_cloner.py` - `py_compile` checks for touched Python modules ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source/<pkg>/changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 63b1257 commit 6dedbb7

27 files changed

Lines changed: 1055 additions & 737 deletions

File tree

docs/source/how-to/cloning.rst

Lines changed: 303 additions & 173 deletions
Large diffs are not rendered by default.

docs/source/overview/core-concepts/multi_backend_architecture.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ This pattern applies to all simulation components:
5656
- :class:`~isaaclab_physx.scene_data_providers.PhysxSceneDataProvider`
5757
- :class:`~isaaclab_newton.scene_data_providers.NewtonSceneDataProvider`
5858
* - Cloner
59-
- :func:`~isaaclab.cloner.clone_from_template`
59+
- :func:`~isaaclab.cloner.usd_replicate`
6060
- :func:`~isaaclab_physx.cloner.physx_replicate`
6161
- :func:`~isaaclab_newton.cloner.newton_physics_replicate`
6262

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
Added
2+
^^^^^
3+
4+
* Added :class:`~isaaclab.cloner.ClonePlan` as the flat clone contract shared by
5+
scene cloning, backend replication, and scene-data providers.
6+
* Added :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` and
7+
:meth:`~isaaclab.sim.SimulationContext.set_clone_plan` for publishing the
8+
scene's clone plan.
9+
* Added :attr:`~isaaclab.scene.InteractiveScene.clone_plan` for consumers holding
10+
a scene reference.
11+
12+
Changed
13+
^^^^^^^
14+
15+
* **Breaking:** Changed scene-data providers to build visualizer backend models
16+
from :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` instead of a
17+
clone-time visualizer artifact. Use the published
18+
:class:`~isaaclab.cloner.ClonePlan` for custom scene-data integrations.
19+
20+
Removed
21+
^^^^^^^
22+
23+
* **Breaking:** Removed
24+
:attr:`~isaaclab.cloner.TemplateCloneCfg.visualizer_clone_fn`,
25+
:func:`~isaaclab.cloner.resolve_visualizer_clone_fn`, and
26+
:class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`.
27+
Use the :class:`~isaaclab.cloner.ClonePlan` published through
28+
:meth:`~isaaclab.sim.SimulationContext.get_clone_plan` instead.
29+
* **Breaking:** Removed
30+
:meth:`~isaaclab.sim.SimulationContext.get_scene_data_visualizer_prebuilt_artifact`,
31+
:meth:`~isaaclab.sim.SimulationContext.set_scene_data_visualizer_prebuilt_artifact`,
32+
and
33+
:meth:`~isaaclab.sim.SimulationContext.clear_scene_data_visualizer_prebuilt_artifact`.
34+
Use :meth:`~isaaclab.sim.SimulationContext.get_clone_plan` /
35+
:meth:`~isaaclab.sim.SimulationContext.set_clone_plan` instead.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
Added
2+
^^^^^
3+
4+
* Added explicit ``spawn_paths`` support to multi-asset spawners so scene
5+
planning can spawn representative heterogeneous sources directly.
6+
7+
Changed
8+
^^^^^^^
9+
10+
* **Breaking:** Changed :class:`~isaaclab.scene.InteractiveScene` to build clone
11+
plans directly from asset configuration, spawn representative sources in their
12+
selected environments, and replicate from those sources instead of spawning and
13+
discovering prototypes under ``/World/template``.
14+
* **Breaking:** Replaced ``TemplateCloneCfg`` with
15+
:class:`~isaaclab.cloner.CloneCfg` for clone execution settings.
16+
* **Breaking:** Changed :func:`~isaaclab.cloner.make_clone_plan` to return a
17+
:class:`~isaaclab.cloner.ClonePlan` object directly.
18+
* **Breaking:** Changed clone plan publication to use
19+
:meth:`~isaaclab.sim.SimulationContext.get_clone_plan` and
20+
:meth:`~isaaclab.sim.SimulationContext.set_clone_plan` for the single scene
21+
clone plan.
22+
23+
Removed
24+
^^^^^^^
25+
26+
* **Breaking:** Removed :func:`~isaaclab.cloner.clone_from_template`. Use
27+
:func:`~isaaclab.cloner.make_clone_plan`,
28+
:func:`~isaaclab.cloner.usd_replicate`, and backend physics replication
29+
functions for direct cloning workflows.

source/isaaclab/isaaclab/cloner/__init__.pyi

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
# SPDX-License-Identifier: BSD-3-Clause
55

66
__all__ = [
7+
"CloneCfg",
78
"ClonePlan",
8-
"TemplateCloneCfg",
99
"random",
1010
"sequential",
11-
"clone_from_template",
1211
"disabled_fabric_change_notifies",
1312
"filter_collisions",
1413
"grid_transforms",
@@ -17,10 +16,9 @@ __all__ = [
1716
]
1817

1918
from .clone_plan import ClonePlan
20-
from .cloner_cfg import TemplateCloneCfg
19+
from .cloner_cfg import CloneCfg
2120
from .cloner_strategies import random, sequential
2221
from .cloner_utils import (
23-
clone_from_template,
2422
disabled_fabric_change_notifies,
2523
filter_collisions,
2624
grid_transforms,

source/isaaclab/isaaclab/cloner/clone_plan.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,29 @@
55

66
from __future__ import annotations
77

8-
from dataclasses import dataclass, field
8+
from dataclasses import dataclass
99

1010
import torch
1111

1212

13-
@dataclass(frozen=True)
13+
@dataclass(frozen=True, eq=False)
1414
class ClonePlan:
15-
"""Per-group mapping from prototype prims to per-environment clones.
16-
17-
Produced by :func:`~isaaclab.cloner.clone_from_template` for each prototype group it
18-
discovers under the template root. Lets downstream consumers (e.g. mesh samplers,
19-
ray-cast sensors) read prototype geometry once and scatter to environments via
20-
:attr:`clone_mask` instead of walking per-env USD paths.
21-
22-
Attributes are population-time invariants and the dataclass is frozen. Hash and
23-
equality operate on :attr:`dest_template` only (the natural identity — it is the key
24-
in :attr:`SimulationContext.get_clone_plans`); the mutable list/tensor fields are
25-
excluded since ``torch.Tensor`` is not hashable and structural equality is rarely the
26-
semantics consumers want.
15+
"""Flat cloning source of truth.
16+
17+
Produced by scene planning after representative source prims are assigned. The
18+
three fields are the same flat replication contract consumed by USD, physics,
19+
and downstream scene-data providers: each source path maps to the destination
20+
template at the same index, and :attr:`clone_mask` selects the environments
21+
populated from that source.
2722
"""
2823

29-
dest_template: str
30-
"""Destination path template for this group, e.g. ``"/World/envs/env_{}/Object"``."""
24+
sources: tuple[str, ...]
25+
"""Source prim paths used for replication."""
3126

32-
prototype_paths: list[str] = field(hash=False, compare=False)
33-
"""Prototype prim paths in this group, e.g.
34-
``["/World/template/Object/proto_asset_0", "/World/template/Object/proto_asset_1"]``."""
27+
destinations: tuple[str, ...]
28+
"""Destination path templates, one per source path."""
3529

36-
clone_mask: torch.Tensor = field(hash=False, compare=False)
37-
"""Boolean tensor of shape ``[num_prototypes_in_group, num_envs]``;
30+
clone_mask: torch.Tensor
31+
"""Boolean tensor of shape ``[len(sources), num_envs]``;
3832
``clone_mask[i, j]`` is ``True`` iff env ``j`` was populated from
39-
:attr:`prototype_paths` ``[i]``. Each column sums to exactly one."""
33+
:attr:`sources` ``[i]``."""

source/isaaclab/isaaclab/cloner/cloner_cfg.py

Lines changed: 5 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,52 +11,14 @@
1111

1212

1313
@configclass
14-
class TemplateCloneCfg:
15-
"""Configuration for template-based cloning.
14+
class CloneCfg:
15+
"""Configuration for environment replication.
1616
17-
This configuration is consumed by :func:`~isaaclab.scene.cloner.clone_from_template` to
18-
replicate one or more "prototype" prims authored under a template root into multiple
19-
per-environment destinations. It supports both USD-spec replication and PhysX replication
20-
and allows choosing between random or round-robin prototype assignment across environments.
21-
22-
The cloning flow is:
23-
24-
1. Discover prototypes under :attr:`template_root` whose base name starts with
25-
:attr:`template_prototype_identifier` (for example, ``proto_asset_0``, ``proto_asset_1``).
26-
2. Build a per-prototype mapping to environments according to
27-
:attr:`random_heterogeneous_cloning` (random) or modulo assignment (deterministic).
28-
3. Stamp the selected prototypes to destinations derived from :attr:`clone_regex`.
29-
4. Optionally perform PhysX replication for the same mapping.
30-
31-
Example
32-
-------
33-
34-
.. code-block:: python
35-
36-
from isaaclab.cloner import TemplateCloneCfg, clone_from_template
37-
from isaaclab.sim.utils.stage import get_current_stage
38-
39-
stage = get_current_stage()
40-
cfg = TemplateCloneCfg(
41-
num_clones=128,
42-
template_root="/World/template",
43-
template_prototype_identifier="proto_asset",
44-
clone_regex="/World/envs/env_.*",
45-
clone_usd=True,
46-
clone_physics=True,
47-
random_heterogeneous_cloning=False, # use round-robin mapping
48-
device="cpu",
49-
)
50-
51-
clone_from_template(stage, num_clones=cfg.num_clones, template_clone_cfg=cfg)
17+
The scene builds a :class:`~isaaclab.cloner.ClonePlan` directly from asset
18+
configuration, spawns the representative source prims, and then uses this
19+
configuration to dispatch USD and physics replication for that plan.
5220
"""
5321

54-
template_root: str = "/World/template"
55-
"""Root path under which template prototypes are authored."""
56-
57-
template_prototype_identifier: str = "proto_asset"
58-
"""Name prefix used to identify prototype prims under :attr:`template_root`."""
59-
6022
clone_regex: str = "/World/envs/env_.*"
6123
"""Destination template for per-environment paths.
6224

source/isaaclab/isaaclab/cloner/cloner_utils.py

Lines changed: 19 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,13 @@
99
import itertools
1010
import logging
1111
import math
12-
from collections.abc import Iterator
13-
from typing import TYPE_CHECKING
12+
from collections.abc import Iterator, Sequence
1413

1514
import torch
1615

1716
from pxr import Gf, Sdf, Usd, UsdGeom, UsdUtils, Vt
1817

19-
import isaaclab.sim as sim_utils
20-
2118
from . import _fabric_notices
22-
23-
if TYPE_CHECKING:
24-
from .cloner_cfg import TemplateCloneCfg
25-
2619
from .clone_plan import ClonePlan
2720

2821
logger = logging.getLogger(__name__)
@@ -105,118 +98,13 @@ def disabled_fabric_change_notifies(stage: Usd.Stage, *, restore: bool = True) -
10598
bindings.set_enable(fabric_id, True)
10699

107100

108-
def clone_from_template(
109-
stage: Usd.Stage, num_clones: int, template_clone_cfg: TemplateCloneCfg
110-
) -> dict[str, ClonePlan]:
111-
"""Clone assets from a template root into per-environment destinations.
112-
113-
This utility discovers prototype prims under ``cfg.template_root`` whose names start with
114-
``cfg.template_prototype_identifier``, builds a per-prototype mapping across
115-
``num_clones`` environments (random or modulo), and then performs USD and/or PhysX replication
116-
according to the flags in ``cfg``.
117-
118-
Args:
119-
stage: The USD stage to author into.
120-
num_clones: Number of environments to clone to (typically equals ``cfg.num_clones``).
121-
template_clone_cfg: Configuration describing template location, destination pattern,
122-
and replication/mapping behavior.
123-
124-
Returns:
125-
Mapping from each group's destination template (e.g. ``"/World/envs/env_{}/Object"``)
126-
to its :class:`ClonePlan`. Empty when no prototype groups are discovered.
127-
128-
Note:
129-
This function suspends the Fabric USD notice listener for the duration of the call
130-
and **leaves it disabled on return**. It is intended to be invoked from a scene-init
131-
path that is followed by :meth:`isaaclab.sim.SimulationContext.reset`, whose Fabric
132-
resync naturally recovers the listener state. Callers that bypass that reset
133-
contract (ad-hoc tooling, unit tests on a bare stage) should re-enable Fabric
134-
notices themselves or wrap the call in
135-
:func:`disabled_fabric_change_notifies` with ``restore=True``.
136-
"""
137-
cfg: TemplateCloneCfg = template_clone_cfg
138-
plans: dict[str, ClonePlan] = {}
139-
# Suspend Fabric's USD notice listener for the duration of bulk authoring. ``restore=False``
140-
# because clone_from_template is only called at scene-init time, which is followed by
141-
# ``SimulationContext.reset`` — that reset path does the Fabric resync naturally, and
142-
# re-enabling here would trigger a redundant ``forceMinimalPopulate`` batch.
143-
with disabled_fabric_change_notifies(stage, restore=False):
144-
world_indices = torch.arange(num_clones, device=cfg.device)
145-
clone_path_fmt = cfg.clone_regex.replace(".*", "{}")
146-
prototype_id = cfg.template_prototype_identifier
147-
prototypes = sim_utils.get_all_matching_child_prims(
148-
cfg.template_root,
149-
predicate=lambda prim: str(prim.GetPath()).split("/")[-1].startswith(prototype_id),
150-
)
151-
if len(prototypes) > 0:
152-
# Canonicalize prototype-root order. Some simulation/visualization backends might apply order-dependent
153-
# processing, so varying USD traversal or set iteration order can change outputs noticeably. Sorting here
154-
# removes that nondeterminism at the source (group order feeds ``make_clone_plan`` and downstream
155-
# replication), which matters for run-to-run reproducibility across IsaacLab's multi-backend stack.
156-
prototype_roots = sorted({"/".join(str(prototype.GetPath()).split("/")[:-1]) for prototype in prototypes})
157-
158-
# discover prototypes per root then make a clone plan
159-
src: list[list[str]] = []
160-
dest: list[str] = []
161-
162-
for prototype_root in prototype_roots:
163-
protos = sim_utils.find_matching_prim_paths(f"{prototype_root}/.*")
164-
protos = [proto for proto in protos if proto.split("/")[-1].startswith(prototype_id)]
165-
src.append(protos)
166-
dest.append(prototype_root.replace(cfg.template_root, clone_path_fmt))
167-
168-
src_paths, dest_paths, clone_masking = make_clone_plan(
169-
src, dest, num_clones, cfg.clone_strategy, cfg.device
170-
)
171-
172-
# Per-group plans: slice ``clone_masking`` along the prototype axis using cumulative
173-
# group sizes — each group's mask rows are contiguous in the ``[total_protos, num_envs]``
174-
# tensor that ``make_clone_plan`` produced.
175-
offsets = [0, *itertools.accumulate(len(g) for g in src)]
176-
plans = {
177-
d: ClonePlan(dest_template=d, prototype_paths=list(ps), clone_mask=clone_masking[lo:hi])
178-
for ps, d, lo, hi in zip(src, dest, offsets, offsets[1:])
179-
}
180-
181-
# Spawn the first instance of clones from prototypes, then deactivate the prototypes, those first
182-
# instances will be served as sources for usd and physics replication.
183-
proto_idx = clone_masking.to(torch.int32).argmax(dim=1)
184-
proto_mask = torch.zeros_like(clone_masking)
185-
proto_mask.scatter_(1, proto_idx.view(-1, 1).to(torch.long), clone_masking.any(dim=1, keepdim=True))
186-
usd_replicate(stage, src_paths, dest_paths, world_indices, proto_mask)
187-
stage.GetPrimAtPath(cfg.template_root).SetActive(False)
188-
get_pos = lambda path: stage.GetPrimAtPath(path).GetAttribute("xformOp:translate").Get() # noqa: E731
189-
positions = torch.tensor([get_pos(clone_path_fmt.format(i)) for i in world_indices])
190-
# Heterogeneous default: emit per-prototype (sources, destinations, mask) and trust
191-
# env_0..N's existing xforms (proto-spawn above already placed them, so don't
192-
# re-author). When every env happens to pick prototype 0, collapse below to a
193-
# single env_0 → all-envs copy and re-author positions (the destination subtree
194-
# replaces env_1..N's prior xform).
195-
sources = [tpl.format(int(idx)) for tpl, idx in zip(dest_paths, proto_idx.tolist())]
196-
usd_positions: torch.Tensor | None = None
197-
if torch.all(proto_idx == 0):
198-
sources = [clone_path_fmt.format(0)]
199-
dest_paths = [clone_path_fmt]
200-
clone_masking = clone_masking.new_ones(1, num_clones)
201-
usd_positions = positions
202-
203-
if cfg.clone_physics and cfg.physics_clone_fn is not None:
204-
cfg.physics_clone_fn(
205-
stage, sources, dest_paths, world_indices, clone_masking, positions=positions, device=cfg.device
206-
)
207-
if cfg.clone_usd:
208-
usd_replicate(stage, sources, dest_paths, world_indices, clone_masking, positions=usd_positions)
209-
210-
return plans
211-
212-
213101
def make_clone_plan(
214-
sources: list[list[str]],
215-
destinations: list[str],
102+
sources: Sequence[Sequence[str]],
103+
destinations: Sequence[str],
216104
num_clones: int,
217105
clone_strategy: callable,
218106
device: str = "cpu",
219-
) -> tuple[list[str], list[str], torch.Tensor]:
107+
) -> ClonePlan:
220108
"""Construct a cloning plan mapping prototype prims to per-environment destinations.
221109
222110
The plan enumerates all combinations of prototypes, selects a combination per environment using ``clone_strategy``,
@@ -231,14 +119,20 @@ def make_clone_plan(
231119
device: Torch device for tensors in the plan. Defaults to ``"cpu"``.
232120
233121
Returns:
234-
tuple: ``(src, dest, masking)`` where ``src`` and ``dest`` are flattened lists of prototype and
235-
destination paths, and ``masking`` is a ``[num_src, num_clones]`` boolean tensor with True
236-
when source ``src[i]`` is used for clone ``j``.
122+
A :class:`ClonePlan` whose ``sources`` and ``destinations`` are flattened per-source rows and
123+
whose ``clone_mask`` is a ``[num_src, num_clones]`` boolean tensor.
237124
"""
238-
# 1) Flatten into src and dest lists
239-
src = [p for group in sources for p in group]
240-
dest = [dst for dst, group in zip(destinations, sources) for _ in group]
125+
if len(sources) != len(destinations):
126+
raise ValueError(f"Expected one destination per source group, got {len(destinations)} and {len(sources)}.")
127+
if not sources:
128+
raise ValueError("Expected at least one source group.")
241129
group_sizes = [len(group) for group in sources]
130+
if any(size == 0 for size in group_sizes):
131+
raise ValueError("Source groups must not be empty.")
132+
133+
# 1) Flatten into src and dest lists
134+
src = tuple(p for group in sources for p in group)
135+
dest = tuple(dst for dst, group in zip(destinations, sources) for _ in group)
242136

243137
# 2) Enumerate all combinations of "one prototype per group"
244138
# all_combos: list of tuples (g0_idx, g1_idx, ..., g_{G-1}_idx)
@@ -256,13 +150,13 @@ def make_clone_plan(
256150

257151
masking = torch.zeros((sum(group_sizes), num_clones), dtype=torch.bool, device=device)
258152
masking[rows, cols] = True
259-
return src, dest, masking
153+
return ClonePlan(sources=src, destinations=dest, clone_mask=masking)
260154

261155

262156
def usd_replicate(
263157
stage: Usd.Stage,
264-
sources: list[str],
265-
destinations: list[str],
158+
sources: Sequence[str],
159+
destinations: Sequence[str],
266160
env_ids: torch.Tensor,
267161
mask: torch.Tensor | None = None,
268162
positions: torch.Tensor | None = None,

0 commit comments

Comments
 (0)