Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Changed
^^^^^^^

* Restored sensors to skip explicit USD replication now that source-only camera views expand from
the clone plan.
9 changes: 6 additions & 3 deletions source/isaaclab/isaaclab/sensors/sensor_base_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ class SensorBaseCfg:
The class should inherit from :class:`isaaclab.sensors.sensor_base.SensorBase`.
"""

cloning_contexts: tuple[str | type, ...] | None = ("isaaclab.cloner:UsdReplicateContext",)
"""Cloning contexts for this sensor. Defaults to USD-only cloning.
cloning_contexts: tuple[str | type, ...] | None = ()
"""Cloning contexts for this sensor. Defaults to no explicit cloning context.

Sensors carry no physics of their own; see :attr:`~isaaclab.assets.AssetBaseCfg.cloning_contexts`.
Sensors carry no physics of their own. When the sensor has a spawner, USD replication is
added automatically under Kit. Listing :class:`~isaaclab.cloner.UsdReplicateContext`
explicitly forces USD replication without Kit; see
:attr:`~isaaclab.assets.AssetBaseCfg.cloning_contexts`.
"""

prim_path: str = MISSING
Expand Down
6 changes: 3 additions & 3 deletions source/isaaclab/test/cloner/test_replicate_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
from isaaclab.sim import SimulationContext


def test_sensor_default_requests_usd_replication():
"""Sensors request USD cloning explicitly so kitless runs author per-env prims."""
def test_sensor_default_does_not_request_a_cloning_context():
"""Sensors rely on automatic Kit replication unless a user explicitly overrides it."""

assert SensorBaseCfg().cloning_contexts == ("isaaclab.cloner:UsdReplicateContext",)
assert SensorBaseCfg().cloning_contexts == ()


@pytest.mark.parametrize(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Fixed
^^^^^

* Fixed source-only world-attached frame views to project across cloned environments without
authoring destination USD prims.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pxr import Gf, Usd, UsdGeom, UsdPhysics

import isaaclab.sim as sim_utils
from isaaclab import cloner
from isaaclab.physics import PhysicsEvent
from isaaclab.sim.views.base_frame_view import BaseFrameView
from isaaclab.sim.views.usd_frame_view import UsdFrameView
Expand Down Expand Up @@ -425,6 +426,7 @@ def _initialize_impl(self, physx: Any) -> None:
self._pose_buf = wp.zeros((1, 7), dtype=wp.float32, device=self._device)
binding_paths = []

world_sites = self._expand_world_sites_from_clone_plan(xform_cache) if not binding_paths else []
# 5. Detect clone_usd=False expansion: binding row count > number of matched USD prims.
# Replace per-prim arrays with one entry per binding row, all derived from the env_0 template.
if binding_paths and len(binding_paths) > len(self._prims):
Expand Down Expand Up @@ -461,6 +463,14 @@ def _initialize_impl(self, physx: Any) -> None:
parent_site_local.append(template_parent_site_local)
synthetic_prim_paths.append(synthetic_path)
self._synthetic_prim_paths: list[str] | None = synthetic_prim_paths
self._prims = [self._prims[0]] * len(binding_paths)
elif world_sites:
_, self._prims, per_prim_site_local, parent_site_local, synthetic_paths = map(
list, zip(*world_sites, strict=True)
)
per_prim_ancestor = [None] * len(world_sites)
parent_ancestor = [None] * len(world_sites)
self._synthetic_prim_paths = synthetic_paths
else:
self._synthetic_prim_paths = None

Expand Down Expand Up @@ -492,6 +502,52 @@ def _initialize_impl(self, physx: Any) -> None:
self._local_pos_ta = ProxyArray(self._local_pos_buf)
self._local_quat_ta = ProxyArray(self._local_quat_buf)

def _expand_world_sites_from_clone_plan(
self, xform_cache: UsdGeom.XformCache
) -> list[tuple[int, Usd.Prim, list[float], list[float], str]]:
"""Return row-ordered source prims and projected poses for source-only world sites."""
sim = sim_utils.SimulationContext.instance()
plan = sim.get_clone_plan() if sim is not None else None
matches = tuple(cloner.query.iter_sources(plan, self._prim_path)) if plan is not None else ()
if sum(len(env_ids) for _, _, _, env_ids in matches) <= len(self._prims):
return []

records: list[tuple[int, Usd.Prim, list[float], list[float], str]] = []
for source_root, destination_template, source_path, env_ids in matches:
source_prim = self._stage.GetPrimAtPath(source_path)
if not source_prim.IsValid():
source_prim = sim_utils.find_first_matching_prim(source_path, self._stage)
if source_prim is None or not source_prim.IsValid():
raise RuntimeError(f"OvPhysxFrameView could not resolve source prim {source_path!r}.")

source_prim_path = source_prim.GetPath().pathString
suffix = cloner.path.relative_to(source_prim_path, source_root)
if suffix is None:
raise RuntimeError(f"OvPhysxFrameView source prim {source_prim_path!r} is not under {source_root!r}.")
source_world = xform_cache.GetLocalToWorldTransform(source_prim)
source_parent_world = xform_cache.GetLocalToWorldTransform(source_prim.GetParent())

for env_id in env_ids:
destination_root = destination_template.format(env_id)
source_anchor_path, destination_anchor_path = source_root, destination_root
destination_anchor = self._stage.GetPrimAtPath(destination_anchor_path)
while not destination_anchor.IsValid() and destination_anchor_path != "/":
source_anchor_path = source_anchor_path.rsplit("/", 1)[0] or "/"
destination_anchor_path = destination_anchor_path.rsplit("/", 1)[0] or "/"
destination_anchor = self._stage.GetPrimAtPath(destination_anchor_path)

source_anchor = self._stage.GetPrimAtPath(source_anchor_path)
if not source_anchor.IsValid() or not destination_anchor.IsValid():
raise RuntimeError(f"OvPhysxFrameView could not project {source_prim_path!r} into env {env_id}.")
source_inverse = xform_cache.GetLocalToWorldTransform(source_anchor).GetInverse()
destination_world = xform_cache.GetLocalToWorldTransform(destination_anchor)
site_world = _gf_matrix_to_xform7(source_world * source_inverse * destination_world)
parent_world = _gf_matrix_to_xform7(source_parent_world * source_inverse * destination_world)
records.append((env_id, source_prim, site_world, parent_world, destination_root + suffix))

records.sort(key=lambda record: record[0])
return records

def _resolve_rigid_body_ancestor(
self,
prim: Usd.Prim,
Expand Down Expand Up @@ -559,11 +615,10 @@ def _env_wildcardify(path: str) -> str:

@property
def prims(self) -> list[Usd.Prim]:
"""List of USD prims discovered for this view.
"""List of one authored USD prim per site.

Under ``clone_usd=False`` scenes only ``env_0`` carries USD prims, so
this list may be shorter than :attr:`count`. Use :attr:`prim_paths` to
get one path per site (env-substituted for non-env_0 sites).
Source-only clones repeat their source prim handle so the list stays aligned with
the view count; prim_paths contains their logical destination paths.
"""
return self._prims

Expand Down Expand Up @@ -868,6 +923,7 @@ def set_visibility(self, visibility, indices: wp.array | None = None) -> None:

def _gf_matrix_to_xform7(mat: Gf.Matrix4d) -> list[float]:
"""Convert a ``Gf.Matrix4d`` to ``[tx, ty, tz, qx, qy, qz, qw]``."""
mat.Orthonormalize()
t = mat.ExtractTranslation()
q = mat.ExtractRotationQuat()
imag = q.GetImaginary()
Expand Down
28 changes: 28 additions & 0 deletions source/isaaclab_ovphysx/test/sim/test_views_xform_prim_ovphysx.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,34 @@ def test_view_raises_before_physics_ready():
view.get_world_poses()


def test_world_attached_source_prim_expands_from_clone_plan():
"""A source-only world frame expands across cloned environments without USD replication."""
device = "cpu"
OVPHYSX_SIM_CFG.device = device
with build_simulation_context(
device=device, sim_cfg=OVPHYSX_SIM_CFG, auto_add_lighting=False, add_ground_plane=False

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.

P2 Rotation projection remains untested

The new transform-composition path is sensitive to matrix order, but this regression covers only translated environment anchors. Add a non-identity source or destination rotation so an ordering regression cannot report incorrect world-frame poses while this test still passes.

Knowledge Base Used: IsaacLab Core Simulation Layer

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

) as sim:
sim._app_control_on_stop_handle = None
scene = InteractiveScene(_OvPhysxFrameViewSceneCfg(num_envs=4, env_spacing=2.0))
sim.reset()

stage = sim_utils.get_current_stage()
prim = stage.DefinePrim("/World/envs/env_0/WorldCamera", "Xform")
sim_utils.standardize_xform_ops(prim)
prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(0.25, -0.5, 1.0))

view = FrameView("/World/envs/env_.*/WorldCamera", device=device)

assert not stage.GetPrimAtPath("/World/envs/env_1/WorldCamera").IsValid()
assert view.count == scene.num_envs
assert len(view.prims) == scene.num_envs
assert {prim.GetPath().pathString for prim in view.prims} == {"/World/envs/env_0/WorldCamera"}
assert view.prim_paths == [f"/World/envs/env_{i}/WorldCamera" for i in range(scene.num_envs)]
positions, _ = view.get_world_poses()
expected_positions = scene.env_origins + torch.tensor([0.25, -0.5, 1.0], device=device)
torch.testing.assert_close(positions.torch, expected_positions)


# Note: an earlier test ``test_view_errors_when_newton_model_not_required`` was
# removed when ``OvPhysxFrameView`` was reworked to read poses from a direct
# OVPhysX ``RIGID_BODY_POSE`` tensor binding instead of the SDP's Newton state.
Expand Down
Loading