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
Expand Up @@ -806,9 +806,9 @@ def test_clone_visualization_builder_ignores_non_env_deformables_on_world_import
fake_builder = _FakeShadowBuilder(body_count=1, cloth_delta=3, track_usd=True)
clone_plan = SimpleNamespace(
sources=("/World/envs/env_0",),
destinations=("/World/envs/env_0", "/World/envs/env_1"),
destinations=("/World/envs/env_{}",),
env_ids=torch.tensor([0, 1], dtype=torch.int32),
clone_mask=torch.tensor([0, 0], dtype=torch.int32),
clone_mask=torch.tensor([[False, False]], dtype=torch.bool),
)
monkeypatch.setattr(vb, "ModelBuilder", lambda up_axis="Z": fake_builder)
monkeypatch.setattr(vb, "_restore_visible_colliders_without_visual_shapes", lambda *args, **kwargs: None)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fixed
^^^^^

* Fixed shadow deformable visualization for clone-planned PhysX environments.
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def build_visualization_builder_from_stage_envs(
)
_restore_visible_colliders_without_visual_shapes(builder, stage, import_result["path_shape_map"])
shadow_entities, registry_groups = add_shadow_deformables_to_builder(
builder, stage, env_paths, device=device, entries=deformable_entries
builder, stage, env_paths, device=device, entries=deformable_entries, clone_plan=clone_plan
)
return builder, (shadow_entities, registry_groups)

Expand Down Expand Up @@ -148,6 +148,6 @@ def build_visualization_builder_from_stage_envs(
replicate_builder_mapping(builder, sources, mapping, positions, quaternions, source_builders)
rename_builder_labels(builder, sources, destinations, env_ids, mapping)
shadow_entities, registry_groups = add_shadow_deformables_to_builder(
builder, stage, env_paths, device=device, entries=deformable_entries
builder, stage, env_paths, device=device, entries=deformable_entries, clone_plan=clone_plan
)
return builder, (shadow_entities, registry_groups)
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@

import logging
from collections.abc import Sequence
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace

import numpy as np
import warp as wp
from newton import ModelBuilder

from pxr import Usd

from isaaclab.cloner import ClonePlan
from isaaclab.scene_data.deformable_discovery import (
DeformableStageEntry,
discover_deformables_on_stage,
Expand Down Expand Up @@ -115,13 +116,63 @@ def _build_volume_vis_remap(entry: DeformableStageEntry, device: str) -> VolumeV
return remap


def _expand_clone_plan_deformable_entries(
entries: Sequence[DeformableStageEntry],
clone_plan: ClonePlan | None,
) -> list[DeformableStageEntry]:
"""Expand prototype deformables into every destination selected by a clone plan.

Kit-less replication may retain only a source deformable on the USD stage. The
shadow builder nevertheless needs one deformable particle block and one visual
mesh binding for every cloned environment.
"""
if clone_plan is None:
return list(entries)

env_ids = clone_plan.env_ids
if env_ids is None:
env_ids = range(clone_plan.clone_mask.shape[1])
else:
env_ids = env_ids.detach().cpu().tolist()

expanded: dict[str, DeformableStageEntry] = {entry.root_path: entry for entry in entries}
for entry in entries:
for source_idx, (source, destination) in enumerate(
zip(clone_plan.sources, clone_plan.destinations, strict=True)
):
source = source.rstrip("/")
if entry.root_path != source and not entry.root_path.startswith(f"{source}/"):
continue

selected_env_ids = clone_plan.clone_mask[source_idx].detach().cpu().tolist()
for env_id, selected in zip(env_ids, selected_env_ids, strict=True):
if not selected:
continue
target = destination.format(int(env_id)).rstrip("/")

def replace_source(path: str) -> str:
return f"{target}{path[len(source) :]}"

cloned_entry = replace(
entry,
root_path=replace_source(entry.root_path),
sim_mesh_path=replace_source(entry.sim_mesh_path),
vis_mesh_path=replace_source(entry.vis_mesh_path),
)
expanded.setdefault(cloned_entry.root_path, cloned_entry)
break

return list(expanded.values())


def add_shadow_deformables_to_builder(
builder: ModelBuilder,
stage: Usd.Stage,
env_paths: Sequence[tuple[int, str]],
*,
device: str = "cpu",
entries: Sequence[DeformableStageEntry] | None = None,
clone_plan: ClonePlan | None = None,
) -> tuple[list[ShadowDeformableEntity], list[ShadowDeformableRegistryGroup]]:
"""Add PhysX/OVPhysX deformable meshes to a shadow Newton builder.

Expand All @@ -134,6 +185,8 @@ def add_shadow_deformables_to_builder(
stage: Current USD stage.
env_paths: Sorted ``(env_id, env_prim_path)`` pairs.
device: Warp device for barycentric remap tables uploaded during shadow build.
clone_plan: Optional replication layout used to expand prototype-only stage
entries into all destination environments.

Returns:
Flat entity list for geometry mapping and grouped registry metadata for
Expand All @@ -143,6 +196,7 @@ def add_shadow_deformables_to_builder(
entries = discover_deformables_on_stage(stage)
if not entries:
return [], []
entries = _expand_clone_plan_deformable_entries(entries, clone_plan)

env_path_by_id = dict(env_paths)
wildcard_groups: dict[tuple[str, str, str], list[DeformableStageEntry]] = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
replicate_builder_mapping,
)
from isaaclab_newton.physics import visualization_builder as visualization_builder_module
from isaaclab_newton.physics import visualization_deformables as visualization_deformables_module
from newton.solvers import SolverMuJoCo

from pxr import Usd, UsdGeom

from isaaclab.cloner import ClonePlan
from isaaclab.scene_data.deformable_discovery import DeformableStageEntry

_VIS_LABEL_SUFFIXES = {
"body_label": "Body",
Expand Down Expand Up @@ -397,6 +399,33 @@ def test_inactive_source_rows_are_ignored(self):


class TestVisualizationClonePlan(unittest.TestCase):
def test_clone_plan_expands_prototype_deformables_to_selected_environments(self):
entry = DeformableStageEntry(
root_path="/World/envs/env_0/Deformable",
sim_mesh_path="/World/envs/env_0/Deformable/simulation_mesh",
vis_mesh_path="/World/envs/env_0/Deformable/visual_mesh",
deformable_type="surface",
vertex_count=3,
vis_vertex_count=3,
)
clone_plan = ClonePlan(
sources=("/World/envs/env_0",),
destinations=("/World/envs/env_{}",),
clone_mask=torch.ones((1, 4), dtype=torch.bool),
env_ids=torch.arange(4),
)

entries = visualization_deformables_module._expand_clone_plan_deformable_entries([entry], clone_plan)

self.assertEqual(
[entry.root_path for entry in entries],
[f"/World/envs/env_{env_id}/Deformable" for env_id in range(4)],
)
self.assertEqual(
[entry.vis_mesh_path for entry in entries],
[f"/World/envs/env_{env_id}/Deformable/visual_mesh" for env_id in range(4)],
)

@staticmethod
def _define_xform(stage, path, translation=None):
xform = UsdGeom.Xform.Define(stage, path)
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 @@ -430,6 +431,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 @@ -466,6 +468,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 @@ -497,6 +507,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 @@ -564,11 +620,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 @@ -873,6 +928,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)


def test_reinitialization_closes_previous_root_view(monkeypatch):
"""A repeated PHYSICS_READY event closes the FrameView's previous root binding."""
from isaaclab_ovphysx.sim.views import OvPhysxFrameView
Expand Down
Loading