Skip to content
Open
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,5 @@
Fixed
^^^^^

* Fixed ``usd_replicate`` so env-origin placement does not overwrite nested camera local transforms,
and copied ``xformOpOrder`` entries are preserved when authoring transform overrides.
48 changes: 43 additions & 5 deletions source/isaaclab/isaaclab/cloner/usd.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,44 @@ def _select_env_ids(env_ids: torch.Tensor, mask: torch.Tensor | None, row: int)
return env_ids[row_mask]


def _source_is_nested_destination_instance(source: str, destination_template: str) -> bool:
"""Return True when ``source`` is a concrete nested instance of ``destination_template``.

Env-origin positions belong on clone roots such as ``/World/envs/env_{}``.
Rows such as ``/World/envs/env_{}/Camera`` already carry a local transform
copied from their source prim and must not receive the env origin as their
local translate.
"""
if "{}" not in destination_template:
return False

prefix, _, suffix = destination_template.partition("{}")
if not suffix:
return False

source = source.rstrip("/") or "/"
prefix = prefix.rstrip("/") if prefix != "/" else prefix
suffix = suffix.rstrip("/")
if not source.startswith(prefix) or not source.endswith(suffix):
return False

slot_end = len(source) - len(suffix)
slot_value = source[len(prefix) : slot_end]
return bool(slot_value) and "/" not in slot_value.strip("/")


def _author_xform_op_order(prim_spec: Sdf.PrimSpec, prim_path: str, authored_op_names: Sequence[str]) -> None:
"""Preserve copied xform ops and append newly-authored ops to ``xformOpOrder``."""
op_order = prim_spec.GetAttributeAtPath(prim_path + ".xformOpOrder") or Sdf.AttributeSpec(
prim_spec, UsdGeom.Tokens.xformOpOrder, Sdf.ValueTypeNames.TokenArray

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: The _source_is_nested_destination_instance logic assumes well-formed USD paths (no double separators). This is a safe assumption given the context, but a brief inline comment noting this constraint could help future maintainers.

)
ordered_ops = list(op_order.default) if op_order.default is not None else []
for op_name in authored_op_names:
if op_name not in ordered_ops:
ordered_ops.append(op_name)
op_order.default = Vt.TokenArray(ordered_ops)


class UsdReplicateContext:
"""Queue and apply USD replication work for one stage."""

Expand Down Expand Up @@ -81,11 +119,14 @@ def queue_mapping(
quaternions: Optional per-environment orientations in xyzw order.
"""
for i, source in enumerate(sources):
# Clone-plan positions are env-root origins. Nested rows already have their
# local transform authored in the source prim, so keep that local transform.
row_positions = None if _source_is_nested_destination_instance(source, destinations[i]) else positions
self.queue(
source,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Question: Should quaternions receive the same nested-row guard as positions? If a nested camera already has an orient op from the source prim, passing env-level quaternions could still overwrite it. If this is intentional (e.g., orient is always per-env), a brief comment documenting that would be helpful.

destinations[i],
_select_env_ids(env_ids, mask, i),
positions=positions,
positions=row_positions,
quaternions=quaternions,
)
Comment on lines +124 to 131

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.

P1 Incomplete guard: quaternions still applied to nested rows

row_positions is set to None for nested rows (e.g. /Camera), but quaternions is passed through unchanged. If a caller supplies env-origin orientations alongside positions — a valid use of the existing API — nested prims will have their local rotation overwritten even though the local translation is protected. The fix should apply the same guard to both transform components.

Suggested change
row_positions = None if _source_is_nested_destination_instance(source, destinations[i]) else positions
self.queue(
source,
destinations[i],
_select_env_ids(env_ids, mask, i),
positions=positions,
positions=row_positions,
quaternions=quaternions,
)
is_nested = _source_is_nested_destination_instance(source, destinations[i])
row_positions = None if is_nested else positions
row_quaternions = None if is_nested else quaternions
self.queue(
source,
destinations[i],
_select_env_ids(env_ids, mask, i),
positions=row_positions,
quaternions=row_quaternions,
)


Expand Down Expand Up @@ -140,10 +181,7 @@ def dp_depth(template: str) -> int:
o_attr.default = Gf.Quatd(float(q[3]), Gf.Vec3d(float(q[0]), float(q[1]), float(q[2])))
op_names.append("xformOp:orient")
if op_names:
op_order = ps.GetAttributeAtPath(dp + ".xformOpOrder") or Sdf.AttributeSpec(
ps, UsdGeom.Tokens.xformOpOrder, Sdf.ValueTypeNames.TokenArray
)
op_order.default = Vt.TokenArray(op_names)
_author_xform_op_order(ps, dp, op_names)


def queue_usd_replication(cfg: Any) -> None:
Expand Down
64 changes: 63 additions & 1 deletion source/isaaclab/test/sim/test_cloner.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import pytest
import torch

from pxr import UsdGeom
from pxr import Gf, UsdGeom

import isaaclab.sim as sim_utils
from isaaclab.cloner import (
Expand Down Expand Up @@ -98,6 +98,68 @@ def test_usd_replicate_with_positions_and_mask(sim):
assert any(op.GetOpType() == UsdGeom.XformOp.TypeTranslate for op in ops)


def _define_ordered_camera(stage, path, translate=(0.57, -0.8, 0.5)):
camera = UsdGeom.Camera.Define(stage, path)
xformable = UsdGeom.Xformable(camera.GetPrim())
xformable.ClearXformOpOrder()
translate_op = xformable.AddTranslateOp(UsdGeom.XformOp.PrecisionDouble)
orient_op = xformable.AddOrientOp(UsdGeom.XformOp.PrecisionDouble)
scale_op = xformable.AddScaleOp(UsdGeom.XformOp.PrecisionDouble)
translate_op.Set(Gf.Vec3d(*translate))
orient_op.Set(Gf.Quatd(0.6124, Gf.Vec3d(0.3536, 0.3536, 0.6124)))
scale_op.Set(Gf.Vec3d(1.0, 1.0, 1.0))
return camera.GetPrim()


def _xform_op_names(prim):
return [op.GetOpName() for op in UsdGeom.Xformable(prim).GetOrderedXformOps()]


def test_usd_replicate_preserves_copied_xform_order_when_authoring_position(sim):
"""Authoring a translate override must not drop copied orient/scale ops from xformOpOrder."""
stage = sim_utils.get_current_stage()
sim_utils.create_prim("/World/template", "Xform")
sim_utils.create_prim("/World/envs", "Xform")
sim_utils.create_prim("/World/envs/env_0", "Xform")
sim_utils.create_prim("/World/envs/env_1", "Xform")
_define_ordered_camera(stage, "/World/template/Camera")

positions = torch.tensor([[0.0, 0.0, 0.0], [1.5, -1.5, 0.0]], dtype=torch.float32, device=sim.cfg.device)
usd_replicate(
stage,
sources=["/World/template/Camera"],
destinations=["/World/envs/env_{}/Camera"],
env_ids=torch.arange(2, dtype=torch.long, device=sim.cfg.device),
positions=positions,
)

prim = stage.GetPrimAtPath("/World/envs/env_1/Camera")
assert prim.GetAttribute("xformOp:translate").Get() == Gf.Vec3d(1.5, -1.5, 0.0)
assert _xform_op_names(prim) == ["xformOp:translate", "xformOp:orient", "xformOp:scale"]


def test_usd_replicate_preserves_nested_env_row_local_camera_xform(sim):
"""Env-origin positions must not overwrite local camera offsets on nested clone rows."""
stage = sim_utils.get_current_stage()
sim_utils.create_prim("/World/envs", "Xform")
sim_utils.create_prim("/World/envs/env_0", "Xform")
sim_utils.create_prim("/World/envs/env_1", "Xform")
_define_ordered_camera(stage, "/World/envs/env_0/Camera")

positions = torch.tensor([[0.0, 0.0, 0.0], [1.5, -1.5, 0.0]], dtype=torch.float32, device=sim.cfg.device)
usd_replicate(
stage,
sources=["/World/envs/env_0/Camera"],
destinations=["/World/envs/env_{}/Camera"],
env_ids=torch.arange(2, dtype=torch.long, device=sim.cfg.device),
positions=positions,
)

prim = stage.GetPrimAtPath("/World/envs/env_1/Camera")
assert prim.GetAttribute("xformOp:translate").Get() == Gf.Vec3d(0.57, -0.8, 0.5)
assert _xform_op_names(prim) == ["xformOp:translate", "xformOp:orient", "xformOp:scale"]


def test_usd_replicate_context_queue_and_replicate(sim):
"""UsdReplicateContext queues copy specs and applies them on replicate."""
sim_utils.create_prim("/World/template", "Xform")
Expand Down
Loading