Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Fixed
^^^^^

* Fixed :func:`~isaaclab.cloner.usd.usd_replicate` authoring environment grid
positions on nested replicated prims (e.g. cameras), overwriting their local
transforms.
2 changes: 2 additions & 0 deletions source/isaaclab/isaaclab/cloner/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ __all__ = [
"REPLICATION_QUEUE",
"replicate",
"resolve_clone_plan_source",
"split_clone_template",
"queue_usd_replication",
"sequential",
"UsdReplicateContext",
Expand All @@ -34,6 +35,7 @@ from .cloner_utils import (
iter_clone_plan_matches,
make_clone_plan,
resolve_clone_plan_source,
split_clone_template,
)
from .replicate_session import (
REPLICATION_QUEUE,
Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab/isaaclab/cloner/clone_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ def from_env_0(
device: Torch device for the mask and env id buffers.
positions: Optional per-env world positions [m], shape ``[num_clones, 3]``.
"""
from .cloner_utils import split_clone_template # noqa: PLC0415
from .replicate_session import REPLICATION_QUEUE # noqa: PLC0415

prefix, _, _ = destination.partition("{}")
prefix, _ = split_clone_template(destination)
cfg_rows: dict[int, tuple[int, ...]] = {
id(cfg): (0,) for cfg, _ in REPLICATION_QUEUE if cfg.prim_path.startswith(prefix)
}
Expand Down
23 changes: 22 additions & 1 deletion source/isaaclab/isaaclab/cloner/cloner_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,27 @@
logger = logging.getLogger(__name__)


def split_clone_template(destination_template: str) -> tuple[str, str]:
"""Split a clone destination template around its clone slot.

The ``"{}"`` slot represents one concrete environment/instance path segment.

Args:
destination_template: Destination path template with ``"{}"`` for the instance id.

Returns:
The ``(prefix, suffix)`` around the clone slot.

Raises:
ValueError: If ``destination_template`` does not contain a clone slot.
"""
destination_template = destination_template.rstrip("/") or "/"
prefix, slot, suffix = destination_template.partition("{}")
if slot != "{}":
raise ValueError(f"Clone destination template must contain '{{}}': {destination_template!r}.")
return prefix, suffix


def get_suffix(path_expr: str, destination_template: str) -> str | None:
"""Return the part of ``path_expr`` below a destination template's env-instance root.

Expand All @@ -47,7 +68,7 @@ def get_suffix(path_expr: str, destination_template: str) -> str | None:
>>> get_suffix("/World/scenes/env_3/sub/Robot/base", tmpl) is None
True
"""
pattern = re.compile(r"[^/]+".join(re.escape(part) for part in destination_template.split("{}")))
pattern = re.compile(r"[^/]+".join(re.escape(part) for part in split_clone_template(destination_template)))
match = pattern.match(path_expr)
if match is None:
return None
Expand Down
25 changes: 18 additions & 7 deletions source/isaaclab/isaaclab/cloner/usd.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pxr import Gf, Sdf, Usd, UsdGeom, Vt

from ._fabric_notices import disabled_fabric_change_notifies
from .cloner_utils import split_clone_template
from .replicate_session import REPLICATION_QUEUE


Expand Down Expand Up @@ -55,8 +56,10 @@ def queue(
source: Source prim path.
destination: Destination path template with ``"{}"`` for env id.
env_ids: Environment ids selected for this source row.
positions: Optional per-environment world positions [m].
quaternions: Optional per-environment orientations in xyzw order.
positions: Optional per-environment world positions [m]. Authored only for
instance-root destination templates (for example, ``.../env_{}``).
quaternions: Optional per-environment orientations in xyzw order. Authored only
for instance-root destination templates (for example, ``.../env_{}``).
"""
self._queue.append((source, destination, env_ids, positions, quaternions))

Expand All @@ -77,8 +80,10 @@ def queue_mapping(
destinations: Destination path templates with ``"{}"`` for env id.
env_ids: Environment indices.
mask: Optional per-source or shared mask.
positions: Optional per-environment world positions [m].
quaternions: Optional per-environment orientations in xyzw order.
positions: Optional per-environment world positions [m]. Authored only for
instance-root destination templates (for example, ``.../env_{}``).
quaternions: Optional per-environment orientations in xyzw order. Authored only
for instance-root destination templates (for example, ``.../env_{}``).
"""
for i, source in enumerate(sources):
self.queue(
Expand Down Expand Up @@ -115,14 +120,18 @@ def dp_depth(template: str) -> int:
for depth in sorted(depth_to_items.keys()):
with Sdf.ChangeBlock():
Comment thread
huidongc marked this conversation as resolved.
for src, tmpl, target_envs, positions, quaternions in depth_to_items[depth]:
_, clone_suffix = split_clone_template(tmpl)
is_instance_root = clone_suffix == ""

for wid in target_envs.tolist():
wid = int(wid)
dp = tmpl.format(wid)
Sdf.CreatePrimInLayer(rl, dp)
if src != dp:
Sdf.CopySpec(rl, Sdf.Path(src), rl, Sdf.Path(dp))

if positions is not None or quaternions is not None:
# Author positions/quaternions for instance roots only.
if is_instance_root and (positions is not None or quaternions is not None):
ps = rl.GetPrimAtPath(dp)
op_names = []
if positions is not None:
Expand Down Expand Up @@ -177,8 +186,10 @@ def usd_replicate(
destinations: Destination formattable templates with ``"{}"`` for env index.
env_ids: Environment indices.
mask: Optional per-source or shared mask. ``None`` selects all.
positions: Optional positions [m], shape ``[E, 3]``, authored as ``xformOp:translate``.
quaternions: Optional orientations in xyzw order, shape ``[E, 4]``, authored as ``xformOp:orient``.
positions: Optional positions [m], shape ``[E, 3]``. Authored as ``xformOp:translate`` only
for env-instance root destinations (``.../env_{}``).
quaternions: Optional orientations in xyzw order, shape ``[E, 4]``. Authored as
``xformOp:orient`` only for env-instance root destinations (``.../env_{}``).
"""
ctx = UsdReplicateContext(stage)
ctx.queue_mapping(sources, destinations, env_ids, mask, positions=positions, quaternions=quaternions)
Expand Down
51 changes: 51 additions & 0 deletions source/isaaclab/test/sim/test_cloner.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
replicate,
resolve_clone_plan_source,
sequential,
split_clone_template,
usd_replicate,
)
from isaaclab.sim import build_simulation_context
Expand All @@ -56,6 +57,14 @@ def _drain_replication_queue():
REPLICATION_QUEUE.clear()


def test_split_clone_template():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This isn't robust enough, we could make this more bullet proof by allowing only one {} occurrence :

d = '/world/env_{}/Camera_{}'
d.partition("{}")
('/world/env_', '{}', '/Camera_{}')

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.

@ooctipus for vis

Do we need to consider a clone template like /world/env_{}/Camera_{}??

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I wasn't suggesting to support, I was suggesting to reject.

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.

it could probably be an ill-formed template in my opinion, but I'd let Octi comment on this.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think lets reserve {} only for world id,
/world/env_{}/Camera_.* -> ok
/world/env_.*/Camera_.* -> ok
/world/env_{}/Camera_{} -> NOT ok

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.

I wasn't suggesting to support, I was suggesting to reject.

I can do this in my second pr. I want to let this pr run thru CI sooner.

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.

a small validation (e.g. destination.count("{}") == 1) in ClonePlan construction or UsdReplicateContext.queue would catch this earlier

"""Split clone destination templates around their clone slot."""
assert split_clone_template("/World/envs/env_{}/Robot") == ("/World/envs/env_", "/Robot")
assert split_clone_template("/World/scenes/{}/") == ("/World/scenes/", "")
with pytest.raises(ValueError, match="must contain"):
split_clone_template("/World/envs/env_0/Robot")


def test_usd_replicate_with_positions_and_mask(sim):
"""Replicate sources to selected envs and author translate ops from positions."""
# Prepare sources under /World/template
Expand Down Expand Up @@ -120,6 +129,48 @@ def test_usd_replicate_context_queue_and_replicate(sim):
assert stage.GetPrimAtPath("/World/envs/env_1/A").IsValid()


def test_usd_replicate_nested_asset_preserves_local_offset_with_positions(sim):
"""Grid positions are authored on env roots but not on nested replicated assets."""
camera_offset = (0.57, -0.8, 0.5)
num_envs = 2
env_ids = torch.arange(num_envs, dtype=torch.long)
positions, _ = grid_transforms(num_envs, 3.0, device=sim.cfg.device)

sim_utils.create_prim("/World/envs", "Xform")
sim_utils.create_prim("/World/envs/env_0", "Xform")
sim_utils.create_prim("/World/envs/env_0/Camera", "Camera", translation=camera_offset)

stage = sim_utils.get_current_stage()
usd_replicate(
stage,
sources=["/World/envs/env_0"],
destinations=["/World/envs/env_{}"],
env_ids=env_ids,
positions=positions,
)
usd_replicate(
stage,
sources=["/World/envs/env_0/Camera"],
destinations=["/World/envs/env_{}/Camera"],
env_ids=env_ids,
positions=positions,
)

for env_idx in range(num_envs):
env_prim = stage.GetPrimAtPath(f"/World/envs/env_{env_idx}")
assert env_prim.IsValid()
env_translate = env_prim.GetAttribute("xformOp:translate").Get()
assert env_translate is not None
expected_env_pos = positions[env_idx].tolist()
assert (env_translate[0], env_translate[1], env_translate[2]) == pytest.approx(expected_env_pos)

camera_prim = stage.GetPrimAtPath(f"/World/envs/env_{env_idx}/Camera")
assert camera_prim.IsValid()
camera_translate = camera_prim.GetAttribute("xformOp:translate").Get()
assert camera_translate is not None
assert (camera_translate[0], camera_translate[1], camera_translate[2]) == pytest.approx(camera_offset)


def test_disabled_fabric_change_notifies_noops_when_usdrt_unavailable(monkeypatch):
"""Fabric notice suspension no-ops when Carbonite bindings exist but ``usdrt`` does not."""
import builtins
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from pxr import UsdPhysics

import isaaclab.sim as sim_utils
from isaaclab.cloner import resolve_clone_plan_source
from isaaclab.cloner import resolve_clone_plan_source, split_clone_template
from isaaclab.sensors.ray_caster.base_ray_caster import BaseRayCaster
from isaaclab.sensors.ray_caster.kernels import (
ALIGNMENT_BASE,
Expand Down Expand Up @@ -117,9 +117,7 @@ def _register_sites_for_expr(self, prim_expr: str) -> list[str]:
plan = sim_utils.SimulationContext.instance().get_clone_plan()
if plan is not None:
for destination_template in plan.destinations:
if "{}" not in destination_template:
continue
destination_prefix, _ = destination_template.split("{}", 1)
destination_prefix, _ = split_clone_template(destination_template)
if attach_expr.startswith(destination_prefix) and "/" not in attach_expr[len(destination_prefix) :]:
return [NewtonManager.cl_register_site(None, identity, per_world=True)]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from pxr import UsdPhysics

import isaaclab.sim as sim_utils
from isaaclab.cloner.cloner_utils import get_suffix, iter_clone_plan_matches
from isaaclab.cloner.cloner_utils import get_suffix, iter_clone_plan_matches, split_clone_template
from isaaclab.physics import PhysicsEvent
from isaaclab.sim.views.base_frame_view import BaseFrameView
from isaaclab.utils.string import resolve_matching_names
Expand Down Expand Up @@ -323,8 +323,8 @@ def _resolve_source_prim(

ref_path = source_root
if source_root is not None and destination_template is not None:
instance_template = destination_template.partition("{}")[0] + "{}"
source_suffix = get_suffix(source_root, instance_template)
template_prefix, _ = split_clone_template(destination_template)
source_suffix = get_suffix(source_root, template_prefix + "{}")
if source_suffix is not None:
ref_path = source_root[: -len(source_suffix)] if source_suffix else source_root
ref_prim = stage.GetPrimAtPath(ref_path) if ref_path is not None else None
Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab_ovphysx/isaaclab_ovphysx/cloner/replicate.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from pxr import Sdf, Usd

from isaaclab.cloner.cloner_utils import split_clone_template
from isaaclab.cloner.replicate_session import REPLICATION_QUEUE


Expand Down Expand Up @@ -90,8 +91,8 @@ def queue_mapping(
for i, src in enumerate(sources):
active_env_ids = _select_env_ids(env_ids, mapping, i).tolist()

pre, _, suf = destinations[i].partition("{}")
self_env_id: int | None = None
pre, suf = split_clone_template(destinations[i])
candidate = src.removeprefix(pre).removesuffix(suf)
if candidate.isdigit():
self_env_id = int(candidate)
Expand Down
3 changes: 2 additions & 1 deletion source/isaaclab_physx/isaaclab_physx/cloner/replicate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from omni.physx import get_physx_replicator_interface
from pxr import Sdf, Usd, UsdUtils

from isaaclab.cloner.cloner_utils import split_clone_template
from isaaclab.cloner.replicate_session import REPLICATION_QUEUE


Expand Down Expand Up @@ -81,7 +82,7 @@ def queue_mapping(
for i, src in enumerate(sources):
worlds = _select_env_ids(env_ids, mapping, i).tolist()
if exclude_self_replication:
pre, _, suf = destinations[i].partition("{}")
pre, suf = split_clone_template(destinations[i])
self_id = src.removeprefix(pre).removesuffix(suf)
if self_id.isdigit():
filtered = [w for w in worlds if w != int(self_id)]
Expand Down
Loading