From bf7fcfcde84c6b62ff780056cef52632b74dc341 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Tue, 9 Jun 2026 05:50:03 -0700 Subject: [PATCH] Fix usd replicate camera xforms --- .../zhengyuz-usd-replicate-camera-xform.rst | 5 ++ source/isaaclab/isaaclab/cloner/usd.py | 48 ++++++++++++-- source/isaaclab/test/sim/test_cloner.py | 64 ++++++++++++++++++- 3 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 source/isaaclab/changelog.d/zhengyuz-usd-replicate-camera-xform.rst diff --git a/source/isaaclab/changelog.d/zhengyuz-usd-replicate-camera-xform.rst b/source/isaaclab/changelog.d/zhengyuz-usd-replicate-camera-xform.rst new file mode 100644 index 000000000000..02ec88bb5f6d --- /dev/null +++ b/source/isaaclab/changelog.d/zhengyuz-usd-replicate-camera-xform.rst @@ -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. diff --git a/source/isaaclab/isaaclab/cloner/usd.py b/source/isaaclab/isaaclab/cloner/usd.py index ace2adffbcf0..7f2939153741 100644 --- a/source/isaaclab/isaaclab/cloner/usd.py +++ b/source/isaaclab/isaaclab/cloner/usd.py @@ -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 + ) + 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.""" @@ -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, destinations[i], _select_env_ids(env_ids, mask, i), - positions=positions, + positions=row_positions, quaternions=quaternions, ) @@ -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: diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index 14e30f4970b9..ea722caca627 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -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 ( @@ -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")