diff --git a/.github/actions/run-tests/run_tests.sh b/.github/actions/run-tests/run_tests.sh index 0588b11f78d4..ce88340d21e9 100755 --- a/.github/actions/run-tests/run_tests.sh +++ b/.github/actions/run-tests/run_tests.sh @@ -270,6 +270,8 @@ run_tests() { -e "HOME=/tmp/isaaclab-ci-home" -e "XDG_CACHE_HOME=/tmp/isaaclab-ci-home/.cache" -e "XDG_DATA_HOME=/tmp/isaaclab-ci-home/.local/share" + # The image's /var/cache/hub belongs to uid 1000, not necessarily the host uid. + -e "HUB__CACHE__PATH=/tmp/isaaclab-ci-home/.cache/hub" -e "USER=${host_user}" -e "LOGNAME=${host_user}" ) diff --git a/docs/source/concepts/backends_and_presets.rst b/docs/source/concepts/backends_and_presets.rst index 7df668c64156..613ba5365704 100644 --- a/docs/source/concepts/backends_and_presets.rst +++ b/docs/source/concepts/backends_and_presets.rst @@ -304,20 +304,37 @@ Renderers and visualizers instead select their implementations through their configuration's ``class_type``. Their selection is independent of physics; renderer instances are shared through the simulation registry described below. -Physics manager lifecycle -^^^^^^^^^^^^^^^^^^^^^^^^^ - -Each backend implements :class:`~isaaclab.physics.PhysicsManager`, the abstract -base class that owns its simulation lifecycle. Implementations initialize their -engine from a :class:`~isaaclab.sim.SimulationContext`, update kinematics with -``forward()``, advance simulation with ``step()``, reset state with ``reset()``, -and release resources with ``close()``. - -The manager exposes :class:`~isaaclab.physics.PhysicsEvent` callbacks for -cross-backend lifecycle work. ``MODEL_INIT`` occurs during scene construction, -``PHYSICS_READY`` after physics initialization, and ``STOP`` before native resources are replaced -or shut down. -The concrete ``close()`` implementation dispatches the ``STOP`` event. +Construction and updates +^^^^^^^^^^^^^^^^^^^^^^^^ + +The task or standalone script composes the simulation. Preset resolution belongs to +``isaaclab_tasks`` and finishes before calling the core launcher, which accepts concrete +configurations and applies launch options. Scene construction and updates follow this order: + +1. :class:`~isaaclab.sim.SimulationContext` constructs the selected physics manager and owns + the resource registry. :class:`~isaaclab.scene.InteractiveScene` owns scene assets and + sensors. A :class:`~isaaclab.cloner.ClonePlan` declares their source definitions, + environment layout, and replication routes before native cloning. +2. Clone contexts prepare their requirements before dispatch. Consumers that share a native + representation combine their asset routes, so physics and rendering do not populate the + same representation independently. Copies come from the plan; authored prototype metadata, + such as mesh topology or scale, is read during setup. +3. Reset initializes physics and binds consumers to its published state. The + :class:`~isaaclab.physics.PhysicsEvent` callbacks order model preparation and physics + readiness. Declared rendering consumers bind before the first graph capture, and visualizers + initialize before the simulation starts playing. +4. Physics steps and explicit state writes invalidate the appropriate publications. Asset + reads and :ref:`scene-data-provider` reads refresh derived state lazily. A read after a + state write does not require a new physics step. Matching native formats are borrowed; + consumers reuse conversions for an unchanged publication. + +Camera-backed views and recordings borrow a camera's output; the camera owns its render product +and buffers, while the simulation owns the renderer. Closing a window leaves its borrowed camera +alive. Native GL perspective views instead render published scene state directly. Teleop and XR +retain bound host Fabric matrices for pose reads; they do not copy poses through GPU buffers. + +Shared resource lifetime +^^^^^^^^^^^^^^^^^^^^^^^^ ``SimulationContext`` keeps native resources and renderers in one registry. ``get_or_create_backend(backend_cfg)`` reuses a resource when the configuration diff --git a/docs/source/concepts/scene_data_providers.rst b/docs/source/concepts/scene_data_providers.rst index 61f6c045ed25..e62c0fa0fad3 100644 --- a/docs/source/concepts/scene_data_providers.rst +++ b/docs/source/concepts/scene_data_providers.rst @@ -75,9 +75,9 @@ The system has three layers: calls return the supplied destination. Destination caches retain indexing metadata, not the consumer's buffers, and expire with the destination. - :meth:`SceneDataProvider.get_camera_transforms`: discovers per-camera, per-env world - transforms from the USD stage. - - :attr:`SceneDataProvider.num_envs`: environment count inferred from - ``/World/envs/env_`` prims. + transforms from the USD stage. Runtime visualizers use bound scene cameras instead. + - :attr:`SceneDataProvider.num_envs`: world count from :class:`~isaaclab.cloner.ClonePlan`, + or ``0`` before a plan is published. Native-only clones need no corresponding USD prims. 3. Backend implementations: diff --git a/docs/source/how-to/cloning.rst b/docs/source/how-to/cloning.rst index cf1969ae58f7..bc01e9a96a46 100644 --- a/docs/source/how-to/cloning.rst +++ b/docs/source/how-to/cloning.rst @@ -29,18 +29,24 @@ asset configurations and an active simulation context ``sim``: .. code-block:: python - from isaaclab.scene import InteractiveScene, InteractiveSceneCfg - from isaaclab.utils import configclass, replace + from isaaclab.cloner import CloneCfg + from isaaclab.scene import InteractiveSceneCfg + from isaaclab.utils import configclass, instantiate, replace @configclass class BananaFrankaSceneCfg(InteractiveSceneCfg): banana = replace(banana_cfg, prim_path="{ENV_REGEX_NS}/Banana") franka = replace(franka_cfg, prim_path="{ENV_REGEX_NS}/Franka") - scene = InteractiveScene(BananaFrankaSceneCfg(num_envs=16, env_spacing=2.0)) + scene = instantiate(BananaFrankaSceneCfg( + num_envs=16, env_spacing=2.0, clone_cfg=CloneCfg(clone_template="/Lab/Cell{}") + )) sim.reset() -This creates sixteen environments, each with one banana and one Franka. +This creates sixteen environments at ``/Lab/Cell0`` through ``/Lab/Cell15``, each +with one banana and one Franka. ``{ENV_REGEX_NS}`` follows the declared template; +omitting ``clone_cfg`` keeps the default ``/World/envs/env_{}`` layout. Shared +assets such as a ground plane keep their explicitly declared paths. Declare cameras alongside the assets so they participate in the same lifecycle. For varying assets across environments, see :doc:`multi_asset_spawning`. diff --git a/scripts/benchmarks/benchmark_view_comparison.py b/scripts/benchmarks/benchmark_view_comparison.py index cfe540416184..9359d08cf673 100644 --- a/scripts/benchmarks/benchmark_view_comparison.py +++ b/scripts/benchmarks/benchmark_view_comparison.py @@ -65,6 +65,7 @@ import isaaclab.sim as sim_utils from isaaclab.sim.views import FrameView +from isaaclab.utils.warp import ProxyArray try: from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg @@ -138,9 +139,9 @@ def benchmark_newton(num_iterations: int) -> dict[str, float]: from pxr import Gf from isaaclab.assets import RigidObjectCfg - from isaaclab.scene import InteractiveScene, InteractiveSceneCfg + from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import build_simulation_context - from isaaclab.utils import configclass + from isaaclab.utils import configclass, instantiate timing_results = {} @@ -162,11 +163,11 @@ class _SceneCfg(InteractiveSceneCfg): ctx = build_simulation_context(device=args_cli.device, sim_cfg=sim_cfgs["newton"], add_ground_plane=True) sim = ctx.__enter__() sim._app_control_on_stop_handle = None - InteractiveScene(_SceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0)) + scene = instantiate(_SceneCfg(num_envs=args_cli.num_envs, env_spacing=2.0)) stage = sim_utils.get_current_stage() - for i in range(args_cli.num_envs): - prim = stage.DefinePrim(f"/World/envs/env_{i}/Cube/Sensor", "Xform") + for env_path in scene.env_prim_paths: + prim = stage.DefinePrim(f"{env_path}/Cube/Sensor", "Xform") sim_utils.standardize_xform_ops(prim) prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(0.1, 0.0, 0.05)) prim.GetAttribute("xformOp:orient").Set(Gf.Quatd(1.0, 0.0, 0.0, 0.0)) @@ -175,7 +176,7 @@ class _SceneCfg(InteractiveSceneCfg): print(f" Newton scene setup: {time.perf_counter() - start_time:.4f}s") start_time = time.perf_counter() - view = NewtonSiteFrameView("/World/envs/env_.*/Cube/Sensor", device=args_cli.device) + view = NewtonSiteFrameView(f"{scene.env_regex_ns}/Cube/Sensor", device=args_cli.device) num_prims = view.count timing_results["init"] = time.perf_counter() - start_time @@ -265,8 +266,8 @@ def _run_pose_benchmarks( num_prims: int, num_iterations: int, timing_results: dict, - positions: wp.array, - orientations: wp.array, + positions: ProxyArray, + orientations: ProxyArray, ): """Shared benchmark loop for get/set world poses on any FrameView.""" start_time = time.perf_counter() @@ -274,10 +275,9 @@ def _run_pose_benchmarks( view.get_world_poses() timing_results["get_world_poses"] = (time.perf_counter() - start_time) / num_iterations - new_positions = wp.clone(positions) - new_positions_t = wp.to_torch(new_positions) - new_positions_t[:, 2] += 0.5 - expected_positions = new_positions_t.clone() + new_positions = positions.torch.clone() + new_positions[:, 2] += 0.5 + expected_positions = new_positions.clone() start_time = time.perf_counter() for _ in range(num_iterations): @@ -286,9 +286,9 @@ def _run_pose_benchmarks( timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations ret_pos, ret_quat = view.get_world_poses() - ret_pos_t = wp.to_torch(ret_pos) - ret_quat_t = wp.to_torch(ret_quat) - ori_t = wp.to_torch(orientations) + ret_pos_t = ret_pos.torch + ret_quat_t = ret_quat.torch + ori_t = orientations.torch pos_ok = torch.allclose(ret_pos_t, expected_positions, atol=1e-4, rtol=0) quat_ok = torch.allclose(ret_quat_t, ori_t, atol=1e-4, rtol=0) diff --git a/scripts/benchmarks/benchmark_xform_prim_view.py b/scripts/benchmarks/benchmark_xform_prim_view.py index 6e46a69e2dd4..6b382bc2b5f3 100644 --- a/scripts/benchmarks/benchmark_xform_prim_view.py +++ b/scripts/benchmarks/benchmark_xform_prim_view.py @@ -96,11 +96,11 @@ def benchmark_frame_view( # noqa: C901 sim = ctx.__enter__() sim._app_control_on_stop_handle = None scene_cfg = _NewtonSceneCfg(num_envs=num_envs, env_spacing=2.0) - instantiate(scene_cfg) + scene = instantiate(scene_cfg) stage = sim_utils.get_current_stage() - for i in range(num_envs): - prim = stage.DefinePrim(f"/World/envs/env_{i}/Object/Sensor", "Xform") + for env_path in scene.env_prim_paths: + prim = stage.DefinePrim(f"{env_path}/Object/Sensor", "Xform") sim_utils.standardize_xform_ops(prim) prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(0.1, 0.0, 0.05)) prim.GetAttribute("xformOp:orient").Set(Gf.Quatd(1.0, 0.0, 0.0, 0.0)) @@ -108,7 +108,7 @@ def benchmark_frame_view( # noqa: C901 sim.reset() start_time = time.perf_counter() - xform_view = NewtonSiteFrameView("/World/envs/env_.*/Object/Sensor", device=device) + xform_view = NewtonSiteFrameView(f"{scene.env_regex_ns}/Object/Sensor", device=device) timing_results["init"] = time.perf_counter() - start_time cleanup = lambda: ctx.__exit__(None, None, None) # noqa: E731 diff --git a/scripts/environments/teleoperation/teleop_replay_agent.py b/scripts/environments/teleoperation/teleop_replay_agent.py index 2f40beec2eca..9ebfad8f2aa1 100644 --- a/scripts/environments/teleoperation/teleop_replay_agent.py +++ b/scripts/environments/teleoperation/teleop_replay_agent.py @@ -1190,6 +1190,7 @@ def _run_single_replay( # its IPC connection. teleop_interface = create_isaac_teleop_device( isaac_teleop_cfg, + env_prim_path=env.scene.env_prim_paths[0], sim_device=args_cli.device, callbacks={}, cloudxr_env_file=None, diff --git a/scripts/environments/teleoperation/teleop_se3_agent.py b/scripts/environments/teleoperation/teleop_se3_agent.py index c6f94f292381..ace82b1450ef 100644 --- a/scripts/environments/teleoperation/teleop_se3_agent.py +++ b/scripts/environments/teleoperation/teleop_se3_agent.py @@ -298,6 +298,7 @@ def stop_teleoperation() -> None: teleop_interface = create_isaac_teleop_device( env_cfg.isaac_teleop, + env_prim_path=env.scene.env_prim_paths[0], sim_device=args_cli.device, callbacks=teleoperation_callbacks, cloudxr_env_file=args_cli.cloudxr_env, diff --git a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py index a902479d3535..c1605907048b 100644 --- a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py +++ b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py @@ -306,16 +306,6 @@ def sync_simulation_state(env: LocomanipulationSDGEnv): env.scene.update(dt=env.physics_dt) -def _set_sensor_camera_view(): - """Set the Sim GUI viewport to display the robot_pov_cam sensor view.""" - import omni.kit.viewport.utility - - viewport = omni.kit.viewport.utility.get_active_viewport() - if viewport is not None: - cam_prim_path = "/World/envs/env_0/Robot/torso_link/d435_link/camera" - viewport.set_active_camera(cam_prim_path) - - def project_robot_state_into_env(env: LocomanipulationSDGEnv, input_episode_data: EpisodeData) -> torch.Tensor: """Project the recorded robot pose and joint state into the current environment. @@ -919,7 +909,11 @@ def replay( env.recorder_manager.record_post_reset(env_ids=[0]) if sensor_camera_view: - _set_sensor_camera_view() + import omni.kit.viewport.utility + + viewport = omni.kit.viewport.utility.get_active_viewport() + if viewport is not None: + viewport.set_active_camera(str(env.scene["robot_pov_cam"]._view.prims[0].GetPath())) # Initialize state machine output_data = LocomanipulationSDGOutputData() diff --git a/scripts/tools/record_demos.py b/scripts/tools/record_demos.py index 8c9f2446f039..6fa8d799a50f 100644 --- a/scripts/tools/record_demos.py +++ b/scripts/tools/record_demos.py @@ -349,7 +349,11 @@ def create_environment_config( def setup_teleop_device( - env_cfg: ManagerBasedRLEnvCfg, callbacks: dict[str, Callable], use_isaac_teleop: bool = False + env_cfg: ManagerBasedRLEnvCfg, + callbacks: dict[str, Callable], + use_isaac_teleop: bool = False, + *, + env_prim_path: str | None = None, ) -> object: """Set up the teleoperation device based on configuration. @@ -358,6 +362,7 @@ def setup_teleop_device( Args: env_cfg: Environment configuration containing the selected teleop devices. + env_prim_path: Concrete environment root for task-relative teleop frames. callbacks: Dictionary mapping callback keys to functions that will be attached to the teleop device use_isaac_teleop: Whether to use Isaac Capture stack instead of native stack @@ -374,6 +379,7 @@ def setup_teleop_device( teleop_interface = create_isaac_teleop_device( env_cfg.isaac_teleop, + env_prim_path=env_prim_path, sim_device=args_cli.device, callbacks=callbacks, cloudxr_env_file=args_cli.cloudxr_env, @@ -580,7 +586,9 @@ def stop_recording_instance(): "RESET": reset_recording_instance, } - teleop_interface = setup_teleop_device(env.cfg, teleoperation_callbacks, use_isaac_teleop) + teleop_interface = setup_teleop_device( + env.cfg, teleoperation_callbacks, use_isaac_teleop, env_prim_path=env.scene.env_prim_paths[0] + ) haptic_driver = None if use_isaac_teleop: diff --git a/scripts/tutorials/05_controllers/run_osc.py b/scripts/tutorials/05_controllers/run_osc.py index 5a0266957b61..368463dab81a 100644 --- a/scripts/tutorials/05_controllers/run_osc.py +++ b/scripts/tutorials/05_controllers/run_osc.py @@ -91,7 +91,7 @@ class SceneCfg(InteractiveSceneCfg): ) contact_forces = ContactSensorCfg( - prim_path="/World/envs/env_.*/TiltedWall", + prim_path="{ENV_REGEX_NS}/TiltedWall", update_period=0.0, history_length=2, debug_vis=False, diff --git a/scripts/tutorials/08_mpm/rigid_body_equivalence.py b/scripts/tutorials/08_mpm/rigid_body_equivalence.py index 5d8150beb664..a955451d74bc 100644 --- a/scripts/tutorials/08_mpm/rigid_body_equivalence.py +++ b/scripts/tutorials/08_mpm/rigid_body_equivalence.py @@ -72,7 +72,7 @@ class ShapeSpec(NamedTuple): RAMP_ROTATION = (math.sin(0.5 * RAMP_ANGLE_RAD), 0.0, 0.0, math.cos(0.5 * RAMP_ANGLE_RAD)) START_Y = 1.70 INITIAL_CLEARANCE = 0.08 -RIGID_BODY_PATTERN = r"/World/envs/env_.*/Rigid_.*" +RIGID_BODY_PATTERN = r"{ENV_REGEX_NS}/Rigid_.*" CAMERA_EYE = (0.0, -26.0, 6.55) CAMERA_TARGET = (0.0, -2.5, 2.15) CAMERA_FOCAL_LENGTH = 18.0 diff --git a/source/isaaclab/changelog.d/pr10-clone-paths.fixed.rst b/source/isaaclab/changelog.d/pr10-clone-paths.fixed.rst new file mode 100644 index 000000000000..3bf4f9d72795 --- /dev/null +++ b/source/isaaclab/changelog.d/pr10-clone-paths.fixed.rst @@ -0,0 +1,2 @@ +* Fixed Kit marker environment ownership for scenes with custom clone namespaces. +* Fixed scene camera discovery and visualizer camera selection to use declared clone paths. diff --git a/source/isaaclab/isaaclab/markers/visualization_markers.py b/source/isaaclab/isaaclab/markers/visualization_markers.py index 79430228f993..cfa435a0cdd3 100644 --- a/source/isaaclab/isaaclab/markers/visualization_markers.py +++ b/source/isaaclab/isaaclab/markers/visualization_markers.py @@ -278,18 +278,18 @@ def _ensure_backends_initialized(self) -> None: ) ) if needs_kit_backend: - self._ensure_kit_backend() + self._ensure_kit_backend(sim) if any( viz.supports_markers() and not viz.pumps_app_update() and viz.cfg.enable_markers for viz in sim.visualizers ): self._ensure_newton_backend() - def _ensure_kit_backend(self) -> None: + def _ensure_kit_backend(self, sim: sim_utils.SimulationContext | None = None) -> None: """Create the Kit marker backend if it is not already active.""" from isaaclab_visualizers.kit.kit_visualization_markers import KitVisualizationMarkers if not any(isinstance(backend, KitVisualizationMarkers) for backend in self._backends): - self._backends.append(KitVisualizationMarkers(self.cfg, visible=self._is_visible)) + self._backends.append(KitVisualizationMarkers(self.cfg, visible=self._is_visible, sim=sim)) def _ensure_newton_backend(self) -> None: """Create the Newton-family marker backend if it is not already active.""" diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index ce0aeb0ef78b..2cb70929c1bb 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -221,7 +221,7 @@ def _collect_asset_cfgs(self) -> tuple[list[Any], tuple[tuple[int, ...], ...] | def filter_collisions(self, global_prim_paths: list[str] | None = None): """Filter environments collisions. - Disables collisions between the environments in ``/World/envs/env_.*`` and enables collisions with the prims + Disables collisions between the declared environments and enables collisions with the prims in global prim paths (e.g. ground plane). Args: @@ -300,12 +300,12 @@ def device(self) -> str: @property def env_ns(self) -> str: - """The namespace ``/World/envs`` in which all environments are created.""" + """The parent namespace of the declared environment template.""" return self._env_fmt.rsplit("/", 1)[0] @property def env_regex_ns(self) -> str: - """The namespace ``/World/envs/env_[^/]+`` in which all environments are created.""" + """The declared environment template with its instance slot expanded to a path expression.""" return self._env_fmt.format("[^/]+") @property diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index d2bec498aa55..a5144286217c 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -5,23 +5,21 @@ from __future__ import annotations -import re from collections import deque -from typing import TYPE_CHECKING, Any +from typing import Any from weakref import WeakValueDictionary, ref import numpy as np import warp as wp +from .. import cloner from .. import sim as sim_utils +from ..cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from ..sensors import camera, contact_sensor from ..utils.buffers import TimestampedBuffer from .geometry_points import convert_geometry_fabric_kernel, convert_geometry_points_kernel from .scene_data_backend import SceneDataBackend, SceneDataFormat -if TYPE_CHECKING: - from pxr import Usd - REQUIRES_STAGE_AND_MODEL: dict[str, tuple[bool, bool]] = { "kit": (True, False), "newton_gl": (False, True), @@ -209,11 +207,47 @@ def get_camera_transforms(self) -> dict[str, Any] | None: Returns: Dictionary with keys ``order`` (list of template prim paths using - ``env_%d``), ``positions`` and ``orientations`` (per-camera, per-env + ``%d`` for the environment index), ``positions`` and ``orientations`` (per-camera, per-env lists, with ``None`` for absent envs), and ``num_envs``. Returns ``None`` when no USD stage is available. """ - return _walk_camera_prims(sim_utils.SimulationContext.instance().stage) + from pxr import Usd, UsdGeom # noqa: PLC0415 + + sim = sim_utils.SimulationContext.instance() + if sim.stage is None: + return None + plan = sim.get_clone_plan() + env_template = plan.env_template if plan is not None else DEFAULT_ENV_TEMPLATE + num_envs = len(plan.topology.world_prototype_layout) if plan is not None else 0 + instances = {} + stage_prims = deque([sim.stage.GetPseudoRoot()]) + while stage_prims: + prim = stage_prims.popleft() + prim_path = str(prim.GetPath()) + world_id, template_path = 0, prim_path + match = cloner.path.match(prim_path, env_template) + if match is not None and match.instance.isdigit(): + world_id = int(match.instance) + template_path = env_template.format("%d") + match.suffix + num_envs = max(num_envs, world_id + 1) + imageable = UsdGeom.Imageable(prim) + if imageable and imageable.ComputeVisibility() == UsdGeom.Tokens.invisible: + continue + if prim.IsA(UsdGeom.Camera): + instances.setdefault(template_path, []).append((world_id, prim)) + stage_prims.extend(prim.GetFilteredChildren(Usd.TraverseInstanceProxies())) + + positions, orientations = [], [] + for cameras in instances.values(): + world_positions, world_orientations = [None] * num_envs, [None] * num_envs + for world_id, prim in cameras: + if world_id < num_envs: + pos, ori = sim_utils.resolve_prim_pose(prim) + world_positions[world_id] = list(map(float, pos)) + world_orientations[world_id] = list(map(float, ori)) + positions.append(world_positions) + orientations.append(world_orientations) + return dict(order=list(instances), positions=positions, orientations=orientations, num_envs=num_envs) def init_output( self, @@ -627,77 +661,3 @@ def convert_Matrix44_to_Matrix44( idx = ConversionKernels.get_output_index(tid, mapping) if idx > -1: output.matrices[idx] = input.matrices[tid] - - -_ENV_PATH_RE = re.compile(r"(?P/World/envs/env_)(?P\d+)(?P/.*)") - - -def _walk_camera_prims(stage: Usd.Stage | None) -> dict[str, Any] | None: - """Walk ``stage`` and collect per-environment camera transforms. - - Args: - stage: USD stage to traverse, or ``None``. - - Returns: - Dictionary with keys ``order`` (template prim paths using ``env_%d``), - ``positions``, ``orientations`` (per-camera, per-env, with ``None`` for - absent envs), and ``num_envs``. Returns ``None`` when ``stage`` is ``None``. - """ - if stage is None: - return None - - from pxr import UsdGeom # noqa: PLC0415 - - shared_paths: list[str] = [] - instances: dict[str, list[tuple[int, str]]] = {} - num_envs = -1 - - stage_prims = deque([stage.GetPseudoRoot()]) - while stage_prims: - prim = stage_prims.popleft() - prim_path = prim.GetPath().pathString - - world_id = 0 - template_path = prim_path - if match := _ENV_PATH_RE.match(prim_path): - world_id = int(match.group("id")) - template_path = match.group("root") + "%d" + match.group("path") - if world_id > num_envs: - num_envs = world_id - - imageable = UsdGeom.Imageable(prim) - if imageable and imageable.ComputeVisibility() == UsdGeom.Tokens.invisible: - continue - - if prim.IsA(UsdGeom.Camera): - instances.setdefault(template_path, []).append((world_id, prim_path)) - if template_path not in shared_paths: - shared_paths.append(template_path) - - if hasattr(UsdGeom, "TraverseInstanceProxies"): - child_prims = prim.GetFilteredChildren(UsdGeom.TraverseInstanceProxies()) - else: - child_prims = prim.GetChildren() - if child_prims: - stage_prims.extend(child_prims) - - num_envs += 1 - positions: list[list[list[float] | None]] = [] - orientations: list[list[list[float] | None]] = [] - - for template_path in shared_paths: - per_world_pos: list[list[float] | None] = [None] * num_envs - per_world_ori: list[list[float] | None] = [None] * num_envs - for world_id, prim_path in instances.get(template_path, []): - if world_id < 0 or world_id >= num_envs: - continue - prim = stage.GetPrimAtPath(prim_path) - if not prim.IsValid(): - continue - pos, ori = sim_utils.resolve_prim_pose(prim) - per_world_pos[world_id] = [float(pos[0]), float(pos[1]), float(pos[2])] - per_world_ori[world_id] = [float(ori[0]), float(ori[1]), float(ori[2]), float(ori[3])] - positions.append(per_world_pos) - orientations.append(per_world_ori) - - return {"order": shared_paths, "positions": positions, "orientations": orientations, "num_envs": num_envs} diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index 648fe2fed0b6..d052dd59aa73 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -755,30 +755,14 @@ def _update_buffers_impl(self, env_mask: wp.array): return self._prepare_camera(env_mask) - sim_ctx = sim_utils.SimulationContext.instance() - renderer = self._renderer - assert renderer is not None - if sim_ctx is not None: - sim_ctx.render_context.render_into_camera( - renderer, - self._render_data, - self._data, - sim_ctx.get_physics_step_count(), - ) - else: - renderer.render(self._render_data) - renderer.read_output(self._render_data, self._data) + self._sim.render_context.render_into_camera( + self._renderer, self._render_data, self._data, self._sim.get_physics_step_count() + ) @staticmethod def _update_buffers_batch_impl(sensors: Sequence[SensorBase]) -> None: """Prepare due cameras and render them together through their shared context.""" cameras = cast(Sequence[Camera], sensors) - sim_ctx = sim_utils.SimulationContext.instance() - if sim_ctx is None: - for camera in cameras: - camera._update_buffers_impl(camera._is_outdated) - return - ready = [] for camera in cameras: if not camera._env_mask_has_any(camera._is_outdated): @@ -788,9 +772,10 @@ def _update_buffers_batch_impl(sensors: Sequence[SensorBase]) -> None: if not ready: return - sim_ctx.render_context.render_into_cameras( + sim = ready[0]._sim + sim.render_context.render_into_cameras( [(camera._renderer, camera._render_data, camera._data) for camera in ready], - sim_ctx.get_physics_step_count(), + sim.get_physics_step_count(), ) """ diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 07ca865273c6..a61089184a9a 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -448,7 +448,7 @@ def initialize_visualizers(self, config_filter: Callable[[Any], bool] | None = N continue path = expand_env_regex_ns(source.prim_path, env_template) pattern = path.replace("%d", "[^/]+").replace("{}", "[^/]+") - pattern = pattern.replace("/World/envs/*", "/World/envs/env_[^/]+") + pattern = re.sub(r"(?<=/)\*(?=/|$)", "[^/]+", pattern) for camera in sensors.values(): if camera.cfg.prim_path == path: break diff --git a/source/isaaclab/isaaclab/sim/utils/queries.py b/source/isaaclab/isaaclab/sim/utils/queries.py index 70c6e43d6eba..a47951a19dbb 100644 --- a/source/isaaclab/isaaclab/sim/utils/queries.py +++ b/source/isaaclab/isaaclab/sim/utils/queries.py @@ -13,6 +13,7 @@ from typing import TYPE_CHECKING from ... import cloner +from ...cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from ..simulation_context import SimulationContext from .stage import get_current_stage @@ -381,7 +382,7 @@ def resolve_matching_prims_from_source( path_expr: str, predicate: Callable[[Usd.Prim], bool] | None = None, expected_num_matches: int | None = None, - env_regex_ns: str = "/World/envs/env_[^/]+", + env_regex_ns: str = DEFAULT_ENV_TEMPLATE.format("[^/]+"), raise_if_no_matches: bool = True, traverse_instance_prims: bool = True, ) -> list[tuple[Usd.Prim, str]]: diff --git a/source/isaaclab/test/cloner/test_clone_plan_algebra.py b/source/isaaclab/test/cloner/test_clone_plan_algebra.py index dde35c2c4205..ba2c2a015f91 100644 --- a/source/isaaclab/test/cloner/test_clone_plan_algebra.py +++ b/source/isaaclab/test/cloner/test_clone_plan_algebra.py @@ -83,7 +83,7 @@ def test_path_match_captures_the_clone_slot(): cloner.path.match("/World/envs/env_3/Robot", invalid) -def test_cloner_imports_without_kit(): +def test_clone_ownership_and_import_boundaries(): """Importing the package in a clean interpreter must not drag in pxr. Topology and path queries must stay independent of USD execution. Loading pxr before @@ -105,14 +105,31 @@ def test_cloner_imports_without_kit(): assert result.returncode == 0, result.stderr assert result.stdout.strip() == "False", "generic clone topology and queries imported the USD backend" - # Only cloning execution and its lifecycle owners may access context instances. - for path in Path(__file__).resolve().parents[3].glob("*/isaaclab*/**/*.py"): - tree = ast.parse(path.read_text()) - if "cloner" in path.parts or path.name in {"simulation_context.py", "render_context.py"}: + source = Path(__file__).resolve().parents[3] + for file in source.glob("*/isaaclab*/**/*.py"): + tree = ast.parse(file.read_text()) + # Only cloning execution and its lifecycle owners may access context instances. + if "cloner" not in file.parts and file.name not in {"simulation_context.py", "render_context.py"}: + assert not any( + isinstance(node, ast.Attribute) and node.attr == "clone_contexts" for node in ast.walk(tree) + ), f"{file} accessed clone contexts; consumers must use the plan and path/query utilities" + # The default layout has one owner; consumers use declarations or bound paths. + if file == source / "isaaclab/isaaclab/cloner/cloner_cfg.py": continue - assert not any(isinstance(node, ast.Attribute) and node.attr == "clone_contexts" for node in ast.walk(tree)), ( - f"{path} accessed clone contexts; consumers must use the plan and path/query utilities" - ) + documentation = { + id(node.value) + for node in ast.walk(tree) + if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) + } + literals = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in documentation + and "/World/envs" in node.value + ] + assert not literals, f"{file}:{literals[0].lineno}: use the declared clone template or bound paths" @pytest.mark.parametrize("shared_assets", [(), (0,)]) diff --git a/source/isaaclab/test/markers/test_visualization_markers.py b/source/isaaclab/test/markers/test_visualization_markers.py index a63a3f902083..40e89ffdebf1 100644 --- a/source/isaaclab/test/markers/test_visualization_markers.py +++ b/source/isaaclab/test/markers/test_visualization_markers.py @@ -27,6 +27,7 @@ from isaaclab_visualizers.viser.viser_visualizer_cfg import ViserBackendCfg import isaaclab.sim as sim_utils +from isaaclab.cloner import make_clone_plan from isaaclab.markers import VisualizationMarkers, VisualizationMarkersCfg from isaaclab.markers.config import FRAME_MARKER_CFG, POSITION_GOAL_MARKER_CFG from isaaclab.sim import SimulationCfg, SimulationContext @@ -139,7 +140,9 @@ def test_marker_backend_selection( )() monkeypatch.setattr(sim_utils.SimulationContext, "instance", staticmethod(lambda: fake_sim)) - monkeypatch.setattr(VisualizationMarkers, "_ensure_kit_backend", lambda self: self._backends.append("kit")) + monkeypatch.setattr( + VisualizationMarkers, "_ensure_kit_backend", lambda self, sim=None: self._backends.append("kit") + ) monkeypatch.setattr(VisualizationMarkers, "_ensure_newton_backend", lambda self: self._backends.append("newton")) marker._ensure_backends_initialized() @@ -195,10 +198,6 @@ def test_environment_ids_author_scene_partitions_and_rebuild_only_on_change(sim, sim._has_offscreen_render = True stage = sim_utils.get_current_stage() - for env_id in range(2): - env_prim = stage.DefinePrim(f"/World/envs/env_{env_id}", "Xform") - env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(f"env_{env_id}") - config = VisualizationMarkersCfg( prim_path="/World/Visuals/cached_partition_marker", markers={"test": sim_utils.SphereCfg(radius=0.1)}, @@ -207,6 +206,14 @@ def test_environment_ids_author_scene_partitions_and_rebuild_only_on_change(sim, translations = torch.tensor([[0.0, 0.0, 0.0], [0.2, 0.0, 0.0]], device=sim.device) environment_ids = torch.tensor([1, 0], device=sim.device) test_marker.visualize(translations=translations, environment_ids=environment_ids) + primvar = UsdGeom.PrimvarsAPI(stage.GetPrimAtPath(test_marker.prim_path)).GetPrimvar("omni:scenePartition") + assert not primvar or not primvar.GetAttr().HasAuthoredValueOpinion() + plan = make_clone_plan((), ((),), 2, env_template="/Lab/Cells/Cell{}") + sim.set_clone_plan(plan) + for env_id in range(2): + env_prim = stage.DefinePrim(plan.env_template.format(env_id), "Xform") + env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(f"env_{env_id}") + test_marker.visualize(translations=translations, environment_ids=environment_ids) primvar = UsdGeom.PrimvarsAPI(stage.GetPrimAtPath(test_marker.prim_path)).GetPrimvar("omni:scenePartition") assert primvar @@ -419,7 +426,6 @@ def render_markers(viewer, visible_env_ids, num_envs, sanitize_group_ids=False): provider = SimpleNamespace( get_transforms=lambda output, **kwargs: False, - get_camera_transforms=lambda: {}, get_contact_sensors=lambda: {}, ) monkeypatch.setattr(module, "render_newton_visualization_markers", render_markers) diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index ada9016b905e..fdcc8d4f3779 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -303,6 +303,7 @@ class _CpuCamera(Camera): def __init__(self, renderer, name, update_period=0.0): self.cfg = SimpleNamespace(update_period=update_period, update_latest_camera_pose=True) + self._sim = renderer._sim self._device = "cpu" self._num_envs = 2 self._is_global = False @@ -414,7 +415,7 @@ def _camera_scene(cameras, lazy=False, **sensors): ) -def test_camera_eager_updates_render_shared_batch_with_current_poses(camera_batch_context): +def test_camera_eager_updates_render_shared_batch_with_current_poses(camera_batch_context, monkeypatch): """Eager scene updates batch initialized cameras and still update other sensor types.""" _, renderer, batches = camera_batch_context cameras = [_CpuCamera(renderer, "wide"), _CpuCamera(renderer, "tele")] @@ -423,6 +424,7 @@ def test_camera_eager_updates_render_shared_batch_with_current_poses(camera_batc uninitialized._is_initialized = False other_sensor = Mock(supports_batch_update=False) scene = _camera_scene([*cameras, uninitialized], other=other_sensor) + monkeypatch.setattr(SimulationContext, "_instance", None) InteractiveScene.update(scene, 0.01) assert batches == [[("wide", 1.0), ("tele", 2.0)]] @@ -437,13 +439,14 @@ def test_camera_eager_updates_render_shared_batch_with_current_poses(camera_batc np.testing.assert_array_equal(uninitialized.frame.warp.numpy(), [0, 0]) -def test_camera_lazy_reads_leave_peer_cameras_outdated(camera_batch_context): +def test_camera_lazy_reads_leave_peer_cameras_outdated(camera_batch_context, monkeypatch): """Reading a lazy camera captures only that camera, even when a peer shares its renderer.""" _, renderer, batches = camera_batch_context cameras = [_CpuCamera(renderer, "wide"), _CpuCamera(renderer, "tele")] cameras[0].pose, cameras[1].pose = 1.0, 2.0 other_sensor = Mock(supports_batch_update=False) scene = _camera_scene(cameras, lazy=True, other=other_sensor) + monkeypatch.setattr(SimulationContext, "_instance", None) InteractiveScene.update(scene, 0.01) assert not batches diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 5ad2bef21f75..52f4699d9254 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -28,6 +28,35 @@ class _Backend(SimpleNamespace, SceneDataBackend): transform_paths = () +def test_camera_discovery_uses_clone_template_and_preserves_missing_rows(monkeypatch): + """USD camera discovery groups custom-root copies without losing world poses or missing rows.""" + from pxr import Gf, Usd, UsdGeom + + from isaaclab.cloner import make_clone_plan + from isaaclab.sim import SimulationContext + + stage = Usd.Stage.CreateInMemory() + plan = make_clone_plan((), ((),), 3, env_template="/Lab/Cells/Cell{}") + for env_id in range(3): + root = UsdGeom.Xform.Define(stage, plan.env_template.format(env_id)) + root.AddTranslateOp().Set(Gf.Vec3d(10 * env_id, 0, 0)) + if env_id != 1: + camera = UsdGeom.Camera.Define(stage, f"{root.GetPath()}/Camera") + camera.AddTranslateOp().Set(Gf.Vec3d(1, 2, 3)) + hidden = UsdGeom.Camera.Define(stage, f"{root.GetPath()}/Hidden") + hidden.CreateVisibilityAttr(UsdGeom.Tokens.invisible) + sim = SimpleNamespace(stage=stage, get_clone_plan=lambda: plan) + monkeypatch.setattr(SimulationContext, "_instance", sim) + provider = SceneDataProvider(_Backend()) + cameras = provider.get_camera_transforms() + assert cameras["num_envs"] == 3 + assert cameras["order"] == ["/Lab/Cells/Cell%d/Camera"] + assert cameras["positions"] == [[[1, 2, 3], None, [21, 2, 3]]] + assert cameras["orientations"] == [[[0, 0, 0, 1], None, [0, 0, 0, 1]]] + sim.stage = None + assert provider.get_camera_transforms() is None + + @pytest.mark.skipif( wp.get_cuda_device_count() == 0, reason="requires a CUDA device to reproduce the default-device mismatch" ) diff --git a/source/isaaclab/test/sim/test_cloner.py b/source/isaaclab/test/sim/test_cloner.py index 51bfc38077f8..f14fdd6ea57a 100644 --- a/source/isaaclab/test/sim/test_cloner.py +++ b/source/isaaclab/test/sim/test_cloner.py @@ -244,44 +244,47 @@ def test_clone_decorator_wildcard_patterns( @pytest.mark.parametrize("with_clone_plan", [True, False]) def test_resolve_matching_prims_from_source(sim, with_clone_plan): """Discovery returns unique source prims, preserving order and multi-instance expressions.""" + env_template = "/Lab/Cells/Cell{}" if with_clone_plan else "/World/envs/env_{}" stage = sim_utils.get_current_stage() for path in ( - "/World/envs/env_0/Robot/foo", - "/World/envs/env_0/Robot/foo/bar", - "/World/envs/env_0/Robot/other", - "/World/envs/env_0/Robot/other/bar", - "/World/envs/env_1/Robot/clone_only", + env_template.format(0) + "/Robot/foo", + env_template.format(0) + "/Robot/foo/bar", + env_template.format(0) + "/Robot/other", + env_template.format(0) + "/Robot/other/bar", + env_template.format(1) + "/Robot/clone_only", ): stage.DefinePrim(path, "Xform") if with_clone_plan: - assets = (AssetBaseCfg(prim_path="/World/envs/env_[^/]+/Robot"),) - cloner.clone_plan_from_env_0(cloner.CloneCfg(), assets, 2, 0.0) + assets = (AssetBaseCfg(prim_path=env_template.format("[^/]+") + "/Robot"),) + cloner.clone_plan_from_env_0(cloner.CloneCfg(clone_template=env_template), assets, 2, 0.0) - matches = queries.resolve_matching_prims_from_source(r"/World/envs/env_[^/]+/Robot/[^A]+") + matches = queries.resolve_matching_prims_from_source(env_template.format("[^/]+") + r"/Robot/[^A]+") # the clone-only prim under env_1 would match the expression if cloned destinations were traversed assert [prim.GetPath().pathString for prim, _ in matches] == [ - "/World/envs/env_0/Robot/foo", - "/World/envs/env_0/Robot/foo/bar", - "/World/envs/env_0/Robot/other", - "/World/envs/env_0/Robot/other/bar", + env_template.format(0) + "/Robot/foo", + env_template.format(0) + "/Robot/foo/bar", + env_template.format(0) + "/Robot/other", + env_template.format(0) + "/Robot/other/bar", ] assert [path_expr for _, path_expr in matches] == [ - "/World/envs/env_[^/]+/Robot/foo", - "/World/envs/env_[^/]+/Robot/foo/bar", - "/World/envs/env_[^/]+/Robot/other", - "/World/envs/env_[^/]+/Robot/other/bar", + env_template.format("[^/]+") + "/Robot/foo", + env_template.format("[^/]+") + "/Robot/foo/bar", + env_template.format("[^/]+") + "/Robot/other", + env_template.format("[^/]+") + "/Robot/other/bar", ] # Each bar is reachable through two matching roots; distinct paths with the same name remain distinct. matches = queries.resolve_matching_prims_from_source( - r"/World/envs/env_[^/]+/Robot/.*", predicate=lambda prim: prim.GetName() == "bar", expected_num_matches=2 + env_template.format("[^/]+") + r"/Robot/.*", + predicate=lambda prim: prim.GetName() == "bar", + expected_num_matches=2, ) assert [prim.GetPath().pathString for prim, _ in matches] == [ - "/World/envs/env_0/Robot/foo/bar", - "/World/envs/env_0/Robot/other/bar", + env_template.format(0) + "/Robot/foo/bar", + env_template.format(0) + "/Robot/other/bar", ] assert [path_expr for _, path_expr in matches] == [ - "/World/envs/env_[^/]+/Robot/foo/bar", - "/World/envs/env_[^/]+/Robot/other/bar", + env_template.format("[^/]+") + "/Robot/foo/bar", + env_template.format("[^/]+") + "/Robot/other/bar", ] diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 6eac87a9cf3c..417e84c0026f 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -74,9 +74,6 @@ def __init__(self, num_envs: int = 0): def num_envs(self) -> int: return self._num_envs - def get_camera_transforms(self): - return None - class _FakeVisualizer(BaseVisualizer): """Minimal visualizer for orchestration tests.""" @@ -391,9 +388,6 @@ class _DummyViserSceneDataProvider: def num_envs(self) -> int: return 4 - def get_camera_transforms(self): - return {} - def create_mapping(self, paths): return None @@ -770,6 +764,7 @@ def test_visualizer_construction_precedes_initialization_and_happens_once(monkey (None, "optical_flow", None, (ValueError, "optical_flow")), ("/Scenes/world_2/Camera", "rgb", [camera], None), ("/Scenes/world_.*/Camera", "rgb", [camera], None), + ("/Scenes/*/Camera", "rgb", [camera], None), ): cfg.cameras = [SceneCameraCfg(prim_path=path)] if path is not None else None cfg.streaming_gt_types = (channel,) diff --git a/source/isaaclab_contrib/changelog.d/pr10-clone-paths.fixed.rst b/source/isaaclab_contrib/changelog.d/pr10-clone-paths.fixed.rst new file mode 100644 index 000000000000..95788ec8a788 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/pr10-clone-paths.fixed.rst @@ -0,0 +1,2 @@ +* Fixed coupled solver body and shape selectors to expand ``{ENV_REGEX_NS}`` from the simulation's + clone template while preserving the original configuration for reuse. diff --git a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py index eb18ce64be9d..6419317b7be0 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py +++ b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler.py @@ -8,7 +8,7 @@ from __future__ import annotations import math -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import partial import warp as wp @@ -27,6 +27,7 @@ from newton.solvers import SolverBase from newton.solvers.experimental.coupled import SolverCoupled, SolverCoupledADMM, SolverCoupledProxy +from isaaclab.cloner.cloner_cfg import expand_env_regex_ns from isaaclab.sim import SimulationContext from isaaclab.utils.string import resolve_matching_names @@ -89,11 +90,24 @@ def _build_solver(self, model: Model, solver_cfg: CouplerCfg) -> None: ) self._validate_config(solver_cfg) - resolved_entries = [self._resolve_entry(model, entry) for entry in solver_cfg.entries] + env_template = self._sim.get_clone_plan().env_template + resolved_entries = [] + for entry in solver_cfg.entries: + bodies = [ + expand_env_regex_ns(body, env_template) if isinstance(body, str) else body for body in entry.bodies + ] + shapes = [expand_env_regex_ns(pattern, env_template) for pattern in entry.shape_label_patterns] + resolved_entries.append( + self._resolve_entry(model, replace(entry, bodies=bodies, shape_label_patterns=shapes)) + ) proxies: list[CouplerProxyMappingCfg] = [] active_proxy_destinations: set[str] = set() if isinstance(solver_cfg, CouplerProxyCfg): - proxies = [self._resolve_proxy(model, proxy) for proxy in solver_cfg.proxies] + for proxy in solver_cfg.proxies: + bodies = [ + expand_env_regex_ns(body, env_template) if isinstance(body, str) else body for body in proxy.bodies + ] + proxies.append(self._resolve_proxy(model, replace(proxy, bodies=bodies))) active_proxy_destinations = {proxy.destination for proxy in proxies if proxy.bodies or proxy.particles} self._validate_resolved_entries(model, resolved_entries, solver_cfg, active_proxy_destinations) entries = [self._build_entry(entry) for entry in resolved_entries] diff --git a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler_cfg.py index bc9b8b57392a..3e65d8230f37 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/coupling/coupler_cfg.py @@ -46,6 +46,7 @@ class CouplerEntryCfg: Each string is treated as a regex matched against full Newton body labels, including all descendant body labels below the matched path. + ``{ENV_REGEX_NS}`` expands to the simulation's declared clone template. """ particles: list[int] = field(default_factory=list) @@ -68,7 +69,7 @@ class CouplerEntryCfg: """Whether this entry owns all shapes whose body index is ``-1``.""" shape_label_patterns: list[str] = field(default_factory=list) - """Regexes matched against full Newton shape labels for additional ownership.""" + """Regexes matched against full Newton shape labels, with ``{ENV_REGEX_NS}`` expansion.""" substeps: int = 1 """Number of equal substeps this entry runs inside one coupled step.""" @@ -97,8 +98,7 @@ class CouplerProxyMappingCfg: String selectors use the full-label-regex semantics of :attr:`CouplerEntryCfg.bodies`. Raw Newton body ids may be given directly as - integers. The coupler resolves selectors to body ids in place, so after build - this list holds only integers. + integers. The coupler resolves a copy at build time, preserving these declarations. """ particles: list[int] = field(default_factory=list) diff --git a/source/isaaclab_contrib/test/coupling/test_coupler.py b/source/isaaclab_contrib/test/coupling/test_coupler.py index 9986a6af20c9..2f6feebb3b93 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler.py @@ -703,6 +703,8 @@ def test_multi_world_mpm_reset_is_not_promoted(monkeypatch): def test_proxy_selects_expected_outer_collision_pipeline(monkeypatch, case, expected_outer): """Proxy entries request shared contacts only when one of their solve paths consumes them.""" model = _FakeModel() + model.body_label = [path.replace("/World/envs/env_0", "/Lab/Cells/Cell0") for path in model.body_label] + model.shape_label = [path.replace("/World/envs/env_0", "/Lab/Cells/Cell0") for path in model.shape_label] if case == "destination_fallback": fallback_proxy = CouplerProxyMappingCfg(source="rigid", destination="soft", bodies=[1], collision_pipeline=None) elif case == "destination_factory_fallback": @@ -711,8 +713,13 @@ def test_proxy_selects_expected_outer_collision_pipeline(monkeypatch, case, expe ) else: fallback_proxy = None - proxy_cfg, resolved_entries, resolved_proxies = _valid_proxy_setup(proxy=fallback_proxy) - recorded_entries: list[str] = [] + proxy_cfg, resolved_entries, _ = _valid_proxy_setup(proxy=fallback_proxy) + for entry, name in zip(resolved_entries, ("Robot", "Object"), strict=True): + entry.config.bodies = [f"{{ENV_REGEX_NS}}/{name}"] + entry.config.particles = entry.particles + resolved_entries[1].config.shape_label_patterns = ["{ENV_REGEX_NS}/Object/.*"] + proxy_cfg.proxies[0].bodies = ["{ENV_REGEX_NS}/Robot/hand"] + recorded_entries, recorded_proxies = [], [] if case == "mjwarp_external": resolved_entries[0].config.solver_cfg = MJWarpSolverCfg(use_mujoco_contacts=False) @@ -722,29 +729,21 @@ def test_proxy_selects_expected_outer_collision_pipeline(monkeypatch, case, expe else: resolved_entries[0].config.solver_cfg = MJWarpSolverCfg() + proxy_cfg.entries = [entry.config for entry in resolved_entries] manager = NewtonCouplerManager() - monkeypatch.setattr( - NewtonCouplerManager, - "_resolve_entry", - lambda self, model, entry_cfg: next(entry for entry in resolved_entries if entry.config.name == entry_cfg.name), - ) - monkeypatch.setattr( - NewtonCouplerManager, - "_resolve_proxy", - lambda self, model, proxy_cfg: next( - p for p in resolved_proxies if (p.source, p.destination) == (proxy_cfg.source, proxy_cfg.destination) - ), - ) + manager._sim = SimpleNamespace(get_clone_plan=lambda: SimpleNamespace(env_template="/Lab/Cells/Cell{}")) + monkeypatch.setattr( NewtonCouplerManager, "_build_entry", - lambda self, entry: recorded_entries.append(entry.config.name) or entry.config.name, + lambda self, entry: recorded_entries.append(entry) or entry.config.name, ) monkeypatch.setattr( NewtonCouplerManager, "_build_proxy_coupled_solver", - lambda self, model, entries, proxies, cfg: SimpleNamespace( + lambda self, model, entries, proxies, cfg: recorded_proxies.extend(proxies) + or SimpleNamespace( kind="proxy", get_proxy_contacts=lambda source, destination: ( None if case in {"destination_fallback", "destination_factory_fallback"} else object() @@ -756,7 +755,12 @@ def test_proxy_selects_expected_outer_collision_pipeline(monkeypatch, case, expe assert manager._needs_collision_pipeline is expected_outer assert manager._supports_contact_sensors is False assert manager._supports_rigid_body_force_input is True - assert recorded_entries == ["rigid", "soft"] + assert [entry.config.name for entry in recorded_entries] == ["rigid", "soft"] + assert [entry.bodies for entry in recorded_entries] == [[0, 1], [2]] + assert recorded_entries[1].shapes == [2] + assert recorded_proxies[0].bodies == [1] + assert proxy_cfg.entries[0].bodies == ["{ENV_REGEX_NS}/Robot"] + assert proxy_cfg.proxies[0].bodies == ["{ENV_REGEX_NS}/Robot/hand"] def test_admm_always_requests_outer_collision_pipeline(monkeypatch): @@ -764,6 +768,7 @@ def test_admm_always_requests_outer_collision_pipeline(monkeypatch): _, resolved_entries, _ = _valid_proxy_setup() cfg = CouplerAdmmCfg(entries=[entry.config for entry in resolved_entries]) manager = NewtonCouplerManager() + manager._sim = SimpleNamespace(get_clone_plan=lambda: SimpleNamespace(env_template="/Lab/Cells/Cell{}")) monkeypatch.setattr( NewtonCouplerManager, "_resolve_entry", diff --git a/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py b/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py index 0704905d0cf7..6ac7e86b4e25 100644 --- a/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py +++ b/source/isaaclab_contrib/test/coupling/test_coupler_runtime.py @@ -33,6 +33,7 @@ def isolated_newton_manager() -> NewtonCouplerManager: """Return a coupler manager with every slot touched by coupler construction set explicitly.""" manager = NewtonCouplerManager() clean_values = { + "_sim": SimpleNamespace(get_clone_plan=lambda: SimpleNamespace(env_template="/Lab/Cells/Cell{}")), "backend": SimpleNamespace(model=None, state_0=None, state_1=None, control=None), "_solver": None, "_use_single_state": None, @@ -63,7 +64,7 @@ def _build_overlapping_body_model(*, mesh_contact: bool = False) -> Model: triangle-pair allocation and contact reduction in the capacity test. """ builder = ModelBuilder(gravity=(0.0, 0.0, -9.81)) - for x, label in ((-0.09, "/World/Source/body"), (0.09, "/World/Destination/body")): + for x, label in ((-0.09, "/Lab/Cells/Cell0/Source/body"), (0.09, "/World/Destination/body")): body = builder.add_body( xform=wp.transform(wp.vec3(x, 0.0, 1.0), wp.quat_identity()), mass=1.0, @@ -89,7 +90,7 @@ def _entry_configs() -> list[CouplerEntryCfg]: CouplerEntryCfg( name="source", solver_cfg=XPBDSolverCfg(iterations=2), - bodies=[r"/World/Source/body"], + bodies=["{ENV_REGEX_NS}/Source/body"], ), CouplerEntryCfg( name="destination", @@ -106,7 +107,7 @@ def test_proxy_destination_can_receive_only_proxy_bodies(isolated_newton_manager CouplerEntryCfg( name="source", solver_cfg=XPBDSolverCfg(iterations=2), - bodies=[r"/World/Source/body"], + bodies=["{ENV_REGEX_NS}/Source/body"], ), CouplerEntryCfg(name="destination", solver_cfg=XPBDSolverCfg(iterations=2)), ], @@ -114,7 +115,7 @@ def test_proxy_destination_can_receive_only_proxy_bodies(isolated_newton_manager CouplerProxyMappingCfg( source="source", destination="destination", - bodies=[r"/World/Source/body"], + bodies=["{ENV_REGEX_NS}/Source/body"], ) ], ) @@ -139,7 +140,7 @@ def test_real_coupler_constructs_resets_and_steps( expected_solver_type: type, isolated_newton_manager, ): - """Step supported coupling configurations without overwriting nested-solver joint state.""" + """Resolve cloned/global selectors and step without overwriting nested-solver joint state.""" model = _build_overlapping_body_model(mesh_contact=algorithm == "admm_capacity") entries = _entry_configs() if algorithm == "proxy": diff --git a/source/isaaclab_mimic/changelog.d/pr10-clone-paths.fixed.rst b/source/isaaclab_mimic/changelog.d/pr10-clone-paths.fixed.rst new file mode 100644 index 000000000000..b3a87cd5e88d --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/pr10-clone-paths.fixed.rst @@ -0,0 +1 @@ +* Fixed generated locomanipulation objects to use the scene's environment namespace. diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env_cfg.py index fed39227714a..fbe822afda29 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env_cfg.py @@ -84,7 +84,7 @@ def add_background_asset(self, background_usd_path: str): def add_forklifts(self, num_forklifts: int): for i in range(num_forklifts): forklift = AssetBaseCfg( - prim_path=f"/World/envs/env_[^/]+/Forklift{i}", + prim_path=f"{{ENV_REGEX_NS}}/Forklift{i}", init_state=AssetBaseCfg.InitialStateCfg(pos=[0.0, 0.0, 0.0], rot=[0.0, 0.0, 0.0, 1.0]), spawn=UsdFileCfg( usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Forklift/forklift.usd", @@ -96,7 +96,7 @@ def add_forklifts(self, num_forklifts: int): def add_boxes(self, num_boxes: int): for i in range(num_boxes): box = AssetBaseCfg( - prim_path=f"/World/envs/env_[^/]+/Box{i}", + prim_path=f"{{ENV_REGEX_NS}}/Box{i}", init_state=AssetBaseCfg.InitialStateCfg(pos=[0.0, 0.0, 0.0], rot=[0.0, 0.0, 0.0, 1.0]), spawn=UsdFileCfg( usd_path=f"{ISAAC_NUCLEUS_DIR}/Environments/Simple_Warehouse/Props/SM_CardBoxB_01_681.usd", diff --git a/source/isaaclab_mimic/test/test_locomanipulation_sdg_cfg.py b/source/isaaclab_mimic/test/test_locomanipulation_sdg_cfg.py index c90163466775..aa6489c0821b 100644 --- a/source/isaaclab_mimic/test/test_locomanipulation_sdg_cfg.py +++ b/source/isaaclab_mimic/test/test_locomanipulation_sdg_cfg.py @@ -23,6 +23,10 @@ def test_sdg_config_loads_before_runtime(): sys.modules["isaaclab_mimic.locomanipulation_sdg.envs.g1_locomanipulation_sdg_env"] = None cfg = parse_env_cfg("Isaac-G1-SteeringWheel-Locomanipulation", device="cpu", num_envs=1) + cfg.scene.add_forklifts(1) + cfg.scene.add_boxes(1) + assert cfg.scene.forklift_0.prim_path == "{ENV_REGEX_NS}/Forklift0" + assert cfg.scene.box_0.prim_path == "{ENV_REGEX_NS}/Box0" assert cfg.scene.num_envs == 1 assert cfg.sim.device == "cpu" assert cfg.actions.upper_body_ik.controller.urdf_path.startswith(ISAACLAB_NUCLEUS_DIR + "/") diff --git a/source/isaaclab_newton/changelog.d/pr10-clone-paths.skip b/source/isaaclab_newton/changelog.d/pr10-clone-paths.skip new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py index 836878e38c1e..76e881976d1c 100644 --- a/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py +++ b/source/isaaclab_newton/isaaclab_newton/cloner/replicate.py @@ -20,6 +20,7 @@ from isaaclab.assets import AssetBaseCfg from isaaclab.cloner import ClonePlan, PrototypeWorldTopology, ReplicateContext from isaaclab.cloner import path as cloner_path +from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from isaaclab.scene_data import SceneDataFormat from isaaclab.scene_data.deformable_discovery import ( deformable_geometry_batches, @@ -315,7 +316,7 @@ def newton_physics_replicate( order = np.argsort(first) members = [np.flatnonzero(mask) for mask in world_masks[order]] shared = np.arange(len(sources), len(sources) + len(global_paths)) - prefix, suffix = destinations[0].split("{}", 1) if destinations else ("/World/envs/env_", "") + prefix, suffix = (destinations[0] if destinations else DEFAULT_ENV_TEMPLATE).split("{}", 1) topology = PrototypeWorldTopology( len(sources) + len(global_paths), np.concatenate((shared, *members)).astype(np.int32), diff --git a/source/isaaclab_ov/changelog.d/pr10-clone-paths.fixed.rst b/source/isaaclab_ov/changelog.d/pr10-clone-paths.fixed.rst new file mode 100644 index 000000000000..1d6a381609c3 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/pr10-clone-paths.fixed.rst @@ -0,0 +1,4 @@ +* Fixed OVRTX camera bindings and partition assignments for custom clone namespaces, + nonzero source environments, and explicitly ordered camera paths. +* Fixed native scene export and OVPhysX frame transforms to follow declared clone destinations + instead of assuming the default environment namespace. diff --git a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py index 1a19435116fe..c4fd71d27c00 100644 --- a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py +++ b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py @@ -726,7 +726,7 @@ def _warmup_and_load(self) -> None: if shared_scene is not None: stage_usda = export_stage_to_string( sim.stage, - len(shared_scene.clone_env_paths), + shared_scene.clone_env_paths, source_paths=tuple(source for source, _ in shared_scene.clone_copies), keep_env_roots=False, env_paths=shared_scene.population_env_paths, diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 66fc5476564c..861d232e090c 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -238,23 +238,17 @@ def close(self) -> None: class OVRTXCameraRenderData: """Owns one camera sensor's native resources and borrowed output buffers.""" - def __init__(self, spec: CameraRenderSpec, render_scope_name: str): + def __init__(self, spec: CameraRenderSpec, render_scope_name: str, camera_paths: list[str]): """Create render data for a camera in its assigned render scope. Args: spec: Camera render specification. render_scope_name: Root scope containing this camera's render product and RenderVars. + camera_paths: Concrete camera paths in sensor row order. """ self.render_scope_name = render_scope_name self.render_product_path = f"/{render_scope_name}/RenderProduct" - source = spec.camera_prim_paths[0] - prefix = "/World/envs/env_0/" - # The renderer creates these clones after loading the prototype scene. - self.camera_paths = ( - [f"/World/envs/env_{i}/{source[len(prefix) :]}" for i in range(spec.num_instances)] - if source.startswith(prefix) - else list(spec.camera_prim_paths) - ) + self.camera_paths = camera_paths self.render_var_keys = { source: f"/{render_scope_name}/Vars/{name}" for source, name in _RENDER_VAR_PRIM_NAMES.items() } @@ -368,7 +362,7 @@ def prepare_stage(self, stage: Usd.Stage, num_envs: int) -> None: _write_file(Path(self.cfg.temp_usd_dir), "pre_ovrtx_renderer_stage.usda", stage.ExportToString()) # Shared physics stages need fully composed instance collider data. usd = export_stage_to_string( - stage, num_envs, source_paths=tuple(source for source, _ in self.scene.clone_copies), + stage, self.scene.clone_env_paths, source_paths=tuple(source for source, _ in self.scene.clone_copies), keep_env_roots=False, env_paths=self.scene.population_env_paths, preserve_references=not ( self._use_ovstage and self.scene.cfg.population_domains & ovstage.PopulationDomain.PHYSICS @@ -517,9 +511,20 @@ def create_render_data(self, spec: CameraRenderSpec) -> OVRTXCameraRenderData: """Register a camera's product and bindings in the prepared scene.""" if str(wp.get_device(spec.device)) != self._device: raise ValueError("Cameras sharing an OVRTX renderer must use the simulation device.") - render_data = OVRTXCameraRenderData(spec, f"RenderCamera_{self.scene.next_camera_id}") - camera_paths = render_data.camera_paths - env_camera = any(camera_paths[0].startswith(path + "/") for path in self.scene.clone_env_paths) + camera_paths = list(spec.camera_prim_paths) + env_partitions = {Sdf.Path(path): f"env_{i}" for i, path in enumerate(self.scene.clone_env_paths)} + source_root = next((root for root in Sdf.Path(camera_paths[0]).GetPrefixes() if root in env_partitions), None) + camera_partitions = [] + if source_root is not None: + # Native cloning may leave only the source camera in USD. Complete paths retain their row order. + if len(camera_paths) < spec.num_instances: + suffix = camera_paths[0][len(str(source_root)) :] + camera_paths = [path + suffix for path in self.scene.clone_env_paths] + camera_partitions = [ + next(env_partitions[root] for root in Sdf.Path(path).GetPrefixes() if root in env_partitions) + for path in camera_paths + ] + render_data = OVRTXCameraRenderData(spec, f"RenderCamera_{self.scene.next_camera_id}", camera_paths) scope, product_path = render_data.render_scope_name, render_data.render_product_path usd = build_render_product_as_string( spec, render_data, device_id=self._warp_device.ordinal, enable_shadows=self.cfg.enable_shadows @@ -553,13 +558,13 @@ def create_render_data(self, spec: CameraRenderSpec) -> OVRTXCameraRenderData: tensors=np.full(spec.num_instances, True, dtype=np.bool_), is_array=False, ).wait() - if env_camera: + if camera_partitions: self.scene.stage.write_attribute( render_data.camera_xform_binding, "omni:scenePartition", ordinal=self.scene.ordinal, tensors=np.array( - [self.scene.paths.intern_token(f"env_{i}") for i in range(spec.num_instances)], + [self.scene.paths.intern_token(partition) for partition in camera_partitions], dtype=np.uint64, ), is_array=False, @@ -585,11 +590,11 @@ def create_render_data(self, spec: CameraRenderSpec) -> OVRTXCameraRenderData: attribute_name="omni:resetXformStack", tensor=np.full(spec.num_instances, True, dtype=np.bool_), ) - if env_camera: + if camera_partitions: self.backend.renderer.write_attribute( camera_paths, "omni:scenePartition", - [f"env_{i}" for i in range(spec.num_instances)], + camera_partitions, semantic=Semantic.TOKEN_STRING, ) render_data.camera_writes = _AsyncWriteBuffers( diff --git a/source/isaaclab_ov/isaaclab_ov/sensors/frame_transformer/frame_transformer.py b/source/isaaclab_ov/isaaclab_ov/sensors/frame_transformer/frame_transformer.py index 5eb634f7a877..835caf57683d 100644 --- a/source/isaaclab_ov/isaaclab_ov/sensors/frame_transformer/frame_transformer.py +++ b/source/isaaclab_ov/isaaclab_ov/sensors/frame_transformer/frame_transformer.py @@ -6,7 +6,6 @@ from __future__ import annotations import logging -import re import warnings from typing import TYPE_CHECKING, Any @@ -179,7 +178,7 @@ def _initialize_impl(self) -> None: ) for prim, matching_prim_path in matches: # Get the name of the body: use relative prim path for unique identification - body_name = self._get_relative_body_path(matching_prim_path) + body_name = matching_prim_path # Use leaf name of prim path if frame name isn't specified by user frame_name = frame if frame is not None else matching_prim_path.rsplit("/", 1)[-1] @@ -238,7 +237,7 @@ def _initialize_impl(self) -> None: num_unique_bodies = len(tracked_body_names) for body_slot, tracked_path in enumerate(tracked_prim_paths): - pattern = self._env_wildcardify(tracked_path) + pattern = path_expr_to_glob(tracked_path) view = OvPhysxView(physx_instance, pattern=pattern, device=self._device) try: binding = view.binding_for(TT.RIGID_BODY_POSE) @@ -267,11 +266,7 @@ def _initialize_impl(self) -> None: # OVPhysX chooses the flat layout itself; _per_env_indices is the identity permutation. self._per_env_indices = list(range(self._num_envs * num_unique_bodies)) - # tracked_prim_paths is already the env-0 representative list in insertion order. - sorted_prim_paths = tracked_prim_paths - - # -- target frames: use relative prim path for unique identification - self._target_frame_body_names = [self._get_relative_body_path(prim_path) for prim_path in sorted_prim_paths] + self._target_frame_body_names = tracked_prim_paths.copy() # -- source frame: retain the concrete body resolved from the configured expression self._source_frame_body_name = tracked_body_names[0] @@ -574,48 +569,3 @@ def _get_connecting_lines( orientations = quat_from_angle_axis(angle, rotation_axis) return positions, orientations, lengths - - @staticmethod - def _env_wildcardify(prim_path: str) -> str: - """Convert an env-0 prim path into an ovphysx fnmatch glob matching all envs. - - Extends the two-substitution pattern used by - :class:`~isaaclab_ov.sensors.ContactSensor` with a third substitution - for concrete ``env_`` paths produced by ``sim_utils.find_matching_prims``. - The three substitutions, in order: - - 1. ``{ENV_REGEX_NS}`` → ``*`` (placeholder form). - 2. ``.*`` → ``*`` (IsaacLab regex form, e.g. ``env_.*``). - 3. ``/envs/env_`` → ``/envs/env_*`` (concrete env-0 path form). - - Args: - prim_path: An env-0 prim path (e.g. ``"/World/envs/env_0/Robot/LF_FOOT"``) or an - IsaacLab regex form (e.g. ``"{ENV_REGEX_NS}/Robot/LF_FOOT"`` or - ``"{ENV_REGEX_NS}/Robot/LF_FOOT"``). - - Returns: - The same path with the env namespace replaced by an fnmatch wildcard - (``*`` for the ``{ENV_REGEX_NS}`` placeholder, ``env_*`` for concrete or - regex env paths). Assumes the standard IsaacLab ``/World/envs/env_/...`` - layout; non-standard scene structures will only get the first two - substitutions. - """ - pattern = re.sub(r"\{ENV_REGEX_NS\}", "*", prim_path) - pattern = path_expr_to_glob(pattern) - pattern = re.sub(r"/envs/env_\d+(/|$)", r"/envs/env_*\1", pattern) - return pattern - - @staticmethod - def _get_relative_body_path(prim_path: str) -> str: - """Strip the ``/envs/env_/`` prefix from a prim path so paths can be compared across environments. - - Args: - prim_path: Absolute USD prim path that may contain an ``/envs/env_/`` segment. - - Returns: - The prim path with that segment collapsed to ``/envs/``, so prim paths from any env compare equal. - """ - # the env slot may be a concrete id or a segment wildcard; try the wildcard spellings - # first so a bare ``[^/]+`` alternative cannot consume half of a character class. - pattern = re.compile(r"/envs/env_(?:\[\^/\][*+]|\.\*|[^/]+)/") - return pattern.sub("/envs/", prim_path) diff --git a/source/isaaclab_ov/isaaclab_ov/sim/views/ovphysx_frame_view.py b/source/isaaclab_ov/isaaclab_ov/sim/views/ovphysx_frame_view.py index 37361ebef04c..40f313dd72fb 100644 --- a/source/isaaclab_ov/isaaclab_ov/sim/views/ovphysx_frame_view.py +++ b/source/isaaclab_ov/isaaclab_ov/sim/views/ovphysx_frame_view.py @@ -14,7 +14,7 @@ import numpy as np import warp as wp -from pxr import Gf, Usd, UsdGeom, UsdPhysics +from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils from isaaclab import cloner @@ -287,11 +287,6 @@ class OvPhysxFrameView(BaseFrameView): Getters return :class:`~isaaclab.utils.warp.ProxyArray`. Setters accept ``wp.array``. - Limitations (v1): - All resolved rigid-body ancestors (plus their USD parents for local-pose - queries) must share a single env-wildcarded path pattern. Mixed - body-types per view raise :class:`NotImplementedError`. The common - case (one body type, wildcarded across envs) is fully supported. """ def __init__(self, prim_path: str, device: str = "cpu", stage: Usd.Stage | None = None, **kwargs): @@ -380,9 +375,8 @@ def _on_physics_ready(self, _event) -> None: def _initialize_impl(self, physx: Any) -> None: """Resolve prims to rigid-body ancestors and create a RIGID_BODY_POSE tensor binding. - With a ClonePlan, site discovery reads only its authored source prims, whether or not - destination USD prims exist. The RIGID_BODY_POSE binding is the source of truth for the - site count, and per-env site paths are synthesized from the source prim paths. + Site discovery reads authored sources and expands their declared destinations. + Native bindings provide the row mapping for those bodies, independent of USD copies. """ from isaaclab_ov import tensor_types as TT # noqa: PLC0415 from isaaclab_ov.sim.views.ovphysx_view import OvPhysxView # noqa: PLC0415 @@ -390,114 +384,89 @@ def _initialize_impl(self, physx: Any) -> None: xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) identity_xform7 = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] - self._contains_physics_frames = any(self._prim_or_template_has_rigid_body_api(prim) for prim in self._prims) - - # 1. Resolve each (template) prim's ancestor body + the prim->ancestor offset. - per_prim_ancestor: list[str | None] = [] - per_prim_site_local: list[list[float]] = [] - for prim in self._prims: - ap, sl = self._resolve_rigid_body_ancestor(prim, xform_cache) - per_prim_ancestor.append(ap) - per_prim_site_local.append(sl) - - # 2. Same resolution for each prim's USD parent (used by local-pose queries). - parent_ancestor: list[str | None] = [] - parent_site_local: list[list[float]] = [] - for prim in self._prims: + self._contains_physics_frames = any(prim.HasAPI(UsdPhysics.RigidBodyAPI) for prim in self._prims) + sources = self._source_sites or [ + (str(prim.GetPath()), str(prim.GetPath()), prim, (-1,)) for prim in self._prims + ] + sites = [] + for source_root, destination_template, prim, env_ids in sources: + body, local = self._resolve_rigid_body_ancestor(prim, xform_cache) parent = prim.GetParent() - if parent and parent.IsValid() and parent.GetPath().pathString != "/": - pap, psl = self._resolve_rigid_body_ancestor(parent, xform_cache) - else: - pap, psl = None, identity_xform7 - parent_ancestor.append(pap) - parent_site_local.append(psl) - - # 3. Dedup discovered ancestor paths into env-wildcarded patterns (one binding per pattern). - all_ancestors = [p for p in (per_prim_ancestor + parent_ancestor) if p is not None] - patterns = sorted({self._env_wildcardify(p) for p in all_ancestors}) - if len(patterns) > 1: - raise NotImplementedError( - f"OvPhysxFrameView v1 supports a single body-type pattern; resolved {len(patterns)}" - f" patterns under prim_path={self._prim_path!r}: {patterns}." + parent_body, parent_local = ( + self._resolve_rigid_body_ancestor(parent, xform_cache) + if parent and not parent.IsPseudoRoot() + else (None, identity_xform7) ) - - # 4. Create the RIGID_BODY_POSE binding (or operate in world-only mode). - if patterns: - pattern = patterns[0] - self._root_view = OvPhysxView(physx, pattern=pattern, device=self._device) - # ``try_binding_for`` returns None for a zero-match binding (the view rejects a - # 0-count binding); surface that as the explicit zero-bodies error below. - self._pose_binding = self._root_view.try_binding_for(TT.RIGID_BODY_POSE) - if self._pose_binding is None: - raise RuntimeError( - f"OvPhysxFrameView: RIGID_BODY_POSE binding for pattern {pattern!r} matched zero bodies." + source_path = Sdf.Path(source_root) + source_inverse = Gf.Matrix4d(1) + if self._source_sites: + source_match = cloner.path.match(source_root, self._clone_plan.env_template) + if source_match is not None: + source_anchor = self._stage.GetPrimAtPath( + self._clone_plan.env_template.format(source_match.instance) + ) + source_inverse = xform_cache.GetLocalToWorldTransform(source_anchor).GetInverse() + for env_id in env_ids: + destination = Sdf.Path(destination_template.format(env_id)) + site_path = str(destination.AppendPath(prim.GetPath().MakeRelativePath(source_path))) + body_path = str(destination.AppendPath(Sdf.Path(body).MakeRelativePath(source_path))) if body else None + parent_path = ( + str(destination.AppendPath(Sdf.Path(parent_body).MakeRelativePath(source_path))) + if parent_body + else None ) + site_local, parent_site_local = local, parent_local + if self._source_sites and (body is None or parent_body is None): + placement = Gf.Matrix4d(1) + if self._clone_plan.positions is not None: + placement.SetTranslateOnly(Gf.Vec3d(*map(float, self._clone_plan.positions[env_id]))) + if body is None: + site_local = _gf_matrix_to_xform7( + xform_cache.GetLocalToWorldTransform(prim) * source_inverse * placement + ) + if parent_body is None: + destination_parent = Sdf.Path(site_path).GetParentPath() + parent_match = cloner.path.match(str(destination_parent), self._clone_plan.env_template) + if parent_match is None: + # A shared parent above the cloned world does not inherit its placement. + parent_world = xform_cache.GetLocalToWorldTransform( + self._stage.GetPrimAtPath(destination_parent) + ) + elif not parent_match.suffix: + parent_world = placement + else: + parent_world = xform_cache.GetLocalToWorldTransform(parent) * source_inverse * placement + parent_site_local = _gf_matrix_to_xform7(parent_world) + sites.append((int(env_id), prim, site_path, body_path, site_local, parent_path, parent_site_local)) + sites.sort(key=lambda site: site[0]) + ( + _, + self._prims, + self._synthetic_prim_paths, + per_prim_ancestor, + per_prim_site_local, + parent_ancestor, + parent_site_local, + ) = map(list, zip(*sites, strict=True)) + + # Bind the declared destinations; native row order is independent of USD discovery order. + body_paths = list(dict.fromkeys(path for path in (*per_prim_ancestor, *parent_ancestor) if path is not None)) + if body_paths: + self._root_view = OvPhysxView(physx, prim_paths=body_paths, device=self._device) + self._pose_binding = self._root_view.binding_for(TT.RIGID_BODY_POSE) self._pose_buf = wp.zeros(self._pose_binding.shape, dtype=wp.float32, device=self._device) - binding_paths: list[str] = list(self._pose_binding.prim_paths) + binding_paths = list(self._pose_binding.prim_paths) else: - # All prims resolved as world-attached: no binding needed; kernels only hit the -1 branch. - self._root_view = None - self._pose_binding = None + self._root_view = self._pose_binding = 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. Expand source prim data to one entry per binding row. - if binding_paths and len(binding_paths) > len(self._prims): - template_ancestor = per_prim_ancestor[0] - template_site_local = per_prim_site_local[0] - template_parent_ancestor = parent_ancestor[0] - template_parent_site_local = parent_site_local[0] - template_path = self._prims[0].GetPath().pathString - - per_prim_ancestor = [] - per_prim_site_local = [] - parent_ancestor = [] - parent_site_local = [] - synthetic_prim_paths: list[str] = [] - for body_path in binding_paths: - env_match = re.search(r"/World/envs/env_(\d+)", body_path) - env_token = env_match.group(0) if env_match else None - # Re-target the template path's env segment to this row's env_id. - if env_token is not None: - synthetic_path = re.sub(r"/World/envs/env_\d+", env_token, template_path) - ap = re.sub(r"/World/envs/env_\d+", env_token, template_ancestor) if template_ancestor else None - pap = ( - re.sub(r"/World/envs/env_\d+", env_token, template_parent_ancestor) - if template_parent_ancestor - else None - ) - else: - synthetic_path = template_path - ap = template_ancestor - pap = template_parent_ancestor - per_prim_ancestor.append(ap) - per_prim_site_local.append(template_site_local) - parent_ancestor.append(pap) - 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 - - # 6. Build site_body and parent_site_body indices into the binding's row order. + # Map each site's body and parent to native binding rows. path_to_row = {p: i for i, p in enumerate(binding_paths)} - site_bodies = [ - path_to_row.get(ap, WORLD_BODY_INDEX) if ap is not None else WORLD_BODY_INDEX for ap in per_prim_ancestor - ] - parent_bodies = [ - path_to_row.get(pap, WORLD_BODY_INDEX) if pap is not None else WORLD_BODY_INDEX for pap in parent_ancestor - ] + site_bodies = [path_to_row[ap] if ap is not None else WORLD_BODY_INDEX for ap in per_prim_ancestor] + parent_bodies = [path_to_row[pap] if pap is not None else WORLD_BODY_INDEX for pap in parent_ancestor] - # 7. Allocate Warp arrays. + # Allocate Warp arrays. device = self._device self._site_body = wp.array(site_bodies, dtype=wp.int32, device=device) self._site_local = wp.array([wp.transform(*x) for x in per_prim_site_local], dtype=wp.transformf, device=device) @@ -516,45 +485,6 @@ 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 plan-ordered source prims and projected poses for source-only world sites.""" - if not self._source_sites: - return [] - plan = self._clone_plan - - sites: list[tuple[int, Usd.Prim, list[float], list[float], str]] = [] - for source_root, destination_template, source_prim, env_ids in self._source_sites: - 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()) - source_match = cloner.path.match(source_root, plan.env_template) - source_anchor_world = Gf.Matrix4d(1.0) - if source_match is not None: - source_anchor_path = plan.env_template.format(source_match.instance) - source_anchor = self._stage.GetPrimAtPath(source_anchor_path) - if not source_anchor.IsValid(): - raise RuntimeError(f"OvPhysxFrameView source anchor {source_anchor_path!r} is not on the stage.") - source_anchor_world = xform_cache.GetLocalToWorldTransform(source_anchor) - source_inverse = source_anchor_world.GetInverse() - - for env_id in env_ids: - env_id = int(env_id) - destination_root = destination_template.format(env_id) - destination_world = Gf.Matrix4d(1.0) - if plan.positions is not None: - destination_world.SetTranslateOnly(Gf.Vec3d(*map(float, plan.positions[env_id]))) - 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) - sites.append((env_id, source_prim, site_world, parent_world, destination_root + suffix)) - - sites.sort(key=lambda site: site[0]) - return sites - def _resolve_rigid_body_ancestor( self, prim: Usd.Prim, @@ -562,13 +492,6 @@ def _resolve_rigid_body_ancestor( ) -> tuple[str | None, list[float]]: """Walk USD ancestors to find the nearest prim with ``UsdPhysics.RigidBodyAPI``. - Under OVPhysX scenes built with ``clone_usd=False`` (the default for - :class:`~isaaclab.scene.InteractiveScene`), only ``env_0`` carries the - authored RigidBodyAPI -- ``env_1..N`` exist only as physics-layer clones - and the corresponding USD prims (when present) are untyped Xforms. - :meth:`_prim_or_template_has_rigid_body_api` handles this by checking - the prim's env_0 equivalent when the API is not directly applied. - Returns: ``(ancestor_path, [tx, ty, tz, qx, qy, qz, qw])``. ``ancestor_path`` is ``None`` when no rigid-body ancestor exists; the local transform in @@ -576,12 +499,12 @@ def _resolve_rigid_body_ancestor( """ prim_world_tf = xform_cache.GetLocalToWorldTransform(prim) prim_world_tf.Orthonormalize() - # If the prim itself is a rigid body (directly or via env_0 template), the site offset is identity. - if self._prim_or_template_has_rigid_body_api(prim): + # A body frame has no offset from itself. + if prim.HasAPI(UsdPhysics.RigidBodyAPI): return prim.GetPath().pathString, [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] ancestor = prim.GetParent() while ancestor and ancestor.IsValid() and ancestor.GetPath().pathString != "/": - if self._prim_or_template_has_rigid_body_api(ancestor): + if ancestor.HasAPI(UsdPhysics.RigidBodyAPI): ancestor_world_tf = xform_cache.GetLocalToWorldTransform(ancestor) ancestor_world_tf.Orthonormalize() local_tf = prim_world_tf * ancestor_world_tf.GetInverse() @@ -589,33 +512,6 @@ def _resolve_rigid_body_ancestor( ancestor = ancestor.GetParent() return None, _gf_matrix_to_xform7(prim_world_tf) - def _prim_or_template_has_rigid_body_api(self, prim: Usd.Prim) -> bool: - """Return whether the prim (or its ``env_0`` equivalent) has ``RigidBodyAPI`` applied. - - Falls back to the env_0 template lookup so that ``clone_usd=False`` envs - (whose USD prims lack physics schemas) still resolve to the right body. - """ - if prim.HasAPI(UsdPhysics.RigidBodyAPI): - return True - path = prim.GetPath().pathString - env_zero_path = self._env_zero_equivalent(path) - if env_zero_path == path: - return False - template_prim = self._stage.GetPrimAtPath(env_zero_path) if self._stage is not None else None - if template_prim is None or not template_prim.IsValid(): - return False - return template_prim.HasAPI(UsdPhysics.RigidBodyAPI) - - @staticmethod - def _env_zero_equivalent(path: str) -> str: - """Replace ``/World/envs/env_`` with ``/World/envs/env_0`` for template lookup.""" - return re.sub(r"/World/envs/env_\d+", "/World/envs/env_0", path) - - @staticmethod - def _env_wildcardify(path: str) -> str: - """Replace ``/World/envs/env_`` with ``/World/envs/env_*`` for binding patterns.""" - return re.sub(r"/World/envs/env_\d+", "/World/envs/env_*", path) - # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @@ -633,9 +529,7 @@ def prims(self) -> list[Usd.Prim]: def prim_paths(self) -> list[str]: """List of one prim path per site. - For ``clone_usd=False`` scenes (where ``env_1..N`` have no USD prim) - the paths are synthesized by replacing ``env_0`` in the template prim's - path with each binding row's env_id. + Native-only clones use the destination paths declared by the clone plan. """ if hasattr(self, "_synthetic_prim_paths") and self._synthetic_prim_paths is not None: return self._synthetic_prim_paths diff --git a/source/isaaclab_ov/isaaclab_ov/stage.py b/source/isaaclab_ov/isaaclab_ov/stage.py index e778081a900d..e9f9f67a0d57 100644 --- a/source/isaaclab_ov/isaaclab_ov/stage.py +++ b/source/isaaclab_ov/isaaclab_ov/stage.py @@ -240,7 +240,7 @@ def reset(self) -> None: def export_stage_to_string( stage: Usd.Stage, - num_envs: int, + clone_env_paths: Sequence[str], source_paths: tuple[str, ...], keep_env_roots: bool = True, *, @@ -255,7 +255,7 @@ def export_stage_to_string( Args: stage: USD stage to export. - num_envs: Number of parallel environments. Source filtering is skipped for one environment. + clone_env_paths: Declared environment roots. Source filtering is skipped for one environment. source_paths: Prototype subtrees retained with their materials and descendants. keep_env_roots: Whether to retain non-source environment roots and their transforms. env_paths: Environment frames to create in the private export before native cloning. @@ -279,27 +279,27 @@ def export_stage_to_string( ).Set(True) export_stage.SetMetadata("renderSettingsPrimPath", settings_path) prim_paths = [] - if num_envs > 1: - envs_path = Sdf.Path("/World/envs") - envs_prim = export_stage.GetPrimAtPath(envs_path) - + if len(clone_env_paths) > 1: source_path_set = frozenset(map(Sdf.Path, source_paths)) retained_env_paths = frozenset(map(Sdf.Path, env_paths)) ancestors = {prefix for source in source_path_set for prefix in source.GetPrefixes()} - prims = iter(Usd.PrimRange(envs_prim)) - next(prims, None) - for prim in prims: - path = prim.GetPath() - if path in source_path_set: - prims.PruneChildren() - elif ( - path not in ancestors - and path not in retained_env_paths - and not (keep_env_roots and path.GetParentPath() == envs_path) - ): - # Kit may already have USD copies; explicit routing may also exclude a prototype. - prim_paths.append(path) - prims.PruneChildren() + for env_path in clone_env_paths: + env_root = export_stage.GetPrimAtPath(env_path) + if not env_root: + continue + prims = iter(Usd.PrimRange(env_root)) + for prim in prims: + path = prim.GetPath() + if path in source_path_set: + prims.PruneChildren() + elif ( + path not in ancestors + and path not in retained_env_paths + and not (keep_env_roots and path == env_root.GetPath()) + ): + # Kit may already have USD copies; routing may also exclude a prototype. + prim_paths.append(path) + prims.PruneChildren() with Sdf.ChangeBlock(): for prim_path in prim_paths: diff --git a/source/isaaclab_ov/test/sensors/test_frame_transformer.py b/source/isaaclab_ov/test/sensors/test_frame_transformer.py index ca4ccc3951a4..b887dd821b92 100644 --- a/source/isaaclab_ov/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_ov/test/sensors/test_frame_transformer.py @@ -653,6 +653,7 @@ def test_frame_transformer_nested_rigid_bodies(device): with _ovphysx_sim_context(device=device) as sim: sim._app_control_on_stop_handle = None scene_cfg = _SceneCfg(num_envs=2, env_spacing=5.0, lazy_sensor_update=False) + scene_cfg.clone_cfg.clone_template = "/Lab/Cells/Cell{}" scene_cfg.robot = replace(FRANKA_PANDA_CFG, prim_path="{ENV_REGEX_NS}/Robot") scene_cfg.robot.spawn.activate_contact_sensors = True scene_cfg.frame_transformer = FrameTransformerCfg( diff --git a/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py b/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py index 22b854ce0a51..fcad2e8e3646 100644 --- a/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py +++ b/source/isaaclab_ov/test/sim/test_views_xform_prim_ovphysx.py @@ -177,13 +177,18 @@ class _OvPhysxFrameViewSceneCfg(InteractiveSceneCfg): def test_body_frame_reads_native_pose(): """Body views follow native writes; only the asset API may move the physics body.""" with build_simulation_context(device="cpu", sim_cfg=OVPHYSX_SIM_CFG) as sim: - scene = InteractiveScene(_OvPhysxFrameViewSceneCfg(num_envs=2, env_spacing=2.0)) + cfg = _OvPhysxFrameViewSceneCfg(num_envs=2, env_spacing=2.0) + cfg.clone_cfg.clone_template = "/Lab/Cells/Cell{}" + cfg.cube.prim_path = "{ENV_REGEX_NS}" + scene = InteractiveScene(cfg) sim.reset() - view = FrameView("/World/envs/env_[^/]+/Cube", device="cpu") + view = FrameView(scene.env_regex_ns, device="cpu") pose = scene["cube"].data.root_link_pose_w.torch.clone() pose[:, 0] += 0.75 scene["cube"].write_root_pose_to_sim_index(root_pose=pose) torch.testing.assert_close(view.get_world_poses()[0].torch, pose[:, :3]) + # Both bodies have the same untransformed parent /Lab/Cells. + torch.testing.assert_close(view.get_local_poses()[0].torch, pose[:, :3]) for writer in (view.xform_world_space_writer, view.xform_local_space_writer): with pytest.raises(ValueError, match="asset API"): writer() diff --git a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py index edb38ad2b0ca..17e6baf9f474 100644 --- a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py +++ b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py @@ -61,14 +61,14 @@ _OVRTX_STAGE_FILE = "ovrtx_renderer_stage.usda" -def _make_multi_env_stage(num_envs: int) -> Usd.Stage: +def _make_multi_env_stage(num_envs: int, env_template: str = "/World/envs/env_{}") -> Usd.Stage: """Build an in-memory stage with distinguishable content per environment.""" stage = Usd.Stage.CreateInMemory() UsdGeom.Xform.Define(stage, "/World") UsdGeom.Xform.Define(stage, "/World/envs") for env_idx in range(num_envs): - env_path = f"/World/envs/env_{env_idx}" + env_path = env_template.format(env_idx) UsdGeom.Xform.Define(stage, env_path) UsdGeom.Xform.Define(stage, f"{env_path}/Robot") UsdGeom.Xform.Define(stage, f"{env_path}/Object_env{env_idx}_only") @@ -364,23 +364,29 @@ def _write_array_attribute(prim_paths: list[str], attribute_name: str, tensors: @pytest.mark.parametrize("suffix", ["", "/Robot"]) def test_prepare_stage_exports_only_clone_sources_and_their_materials(monkeypatch, suffix): """Export routed prototypes and materials, excluding unrouted sources and cloned descendants.""" - stage = _make_multi_env_stage(3) + env_template = "/Lab/Cells/Cell{}" if suffix else "/World/envs/env_{}" + stage = _make_multi_env_stage(3, env_template) + UsdGeom.Cube.Define(stage, "/Lab/Unrelated") for env_id in range(3): - root = UsdGeom.Xform(stage.GetPrimAtPath(f"/World/envs/env_{env_id}")) + root = UsdGeom.Xform(stage.GetPrimAtPath(env_template.format(env_id))) root.AddTranslateOp().Set(Gf.Vec3d(3 * env_id, 0, 0)) root.GetPrim().CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(f"env_{env_id}") - stage.GetPrimAtPath("/World/envs/env_0").ClearTypeName() - stage.RemovePrim("/World/envs/env_2") - source = f"/World/envs/env_0{suffix}" + stage.GetPrimAtPath(env_template.format(0)).ClearTypeName() + stage.RemovePrim(env_template.format(2)) + source = env_template.format(0) + suffix material = UsdShade.Material.Define(stage, f"{source}/warm") body = UsdGeom.Xform.Define(stage, f"{source}/Body").GetPrim() UsdShade.MaterialBindingAPI.Apply(body) UsdShade.MaterialBindingAPI(body).Bind(material) - asset = AssetBaseCfg(prim_path=f"/World/envs/env_[^/]+{suffix}", spawn=SpawnerCfg(spawn_path=source)) - excluded_path = "/World/envs/env_1/Excluded" + asset = AssetBaseCfg(prim_path=env_template.format("[^/]+") + suffix, spawn=SpawnerCfg(spawn_path=source)) + excluded_path = env_template.format(1) + "/Excluded" UsdGeom.Xform.Define(stage, excluded_path) - excluded = AssetBaseCfg(prim_path="/World/envs/env_[^/]+/Excluded", spawn=SpawnerCfg(spawn_path=excluded_path)) - plan = make_clone_plan((asset, excluded), ((0, 1),), 3, positions=np.zeros((3, 3), dtype=np.float32)) + excluded = AssetBaseCfg( + prim_path=env_template.format("[^/]+") + "/Excluded", spawn=SpawnerCfg(spawn_path=excluded_path) + ) + plan = make_clone_plan( + (asset, excluded), ((0, 1),), 3, env_template=env_template, positions=np.zeros((3, 3), dtype=np.float32) + ) renderer = _make_ovrtx_renderer_without_backend() _prepare_clones(renderer, plan, routed=(0,)) authored_stage = stage.ExportToString() @@ -405,7 +411,7 @@ def test_prepare_stage_exports_only_clone_sources_and_their_materials(monkeypatc backend.populate(exported_usd) assert imported.call_args.args == (native, exported_usd) assert imported.call_args.kwargs["domains"] == ovstage.PopulationDomain.ALL - native.clone.assert_called_once_with(source, [f"/World/envs/env_{i}{suffix}" for i in (1, 2)], ordinal=1) + native.clone.assert_called_once_with(source, [env_template.format(i) + suffix for i in (1, 2)], ordinal=1) native.advance_write_floor.assert_called_once_with(ordinal=1) backend.close() exported = Usd.Stage.CreateInMemory() @@ -415,12 +421,13 @@ def test_prepare_stage_exports_only_clone_sources_and_their_materials(monkeypatc binding = UsdShade.MaterialBindingAPI(exported.GetPrimAtPath(f"{source}/Body")).GetDirectBindingRel() assert binding.GetTargets() == [Sdf.Path(f"{source}/warm")] assert exported.GetPrimAtPath(f"{source}/warm") - assert exported.GetPrimAtPath("/World/envs/env_0").IsA(UsdGeom.Xform) + assert exported.GetPrimAtPath(env_template.format(0)).IsA(UsdGeom.Xform) + assert exported.GetPrimAtPath("/Lab/Unrelated").IsA(UsdGeom.Cube) assert not exported.GetPrimAtPath(excluded_path) - assert exported.GetPrimAtPath("/World/envs/env_0/Robot") - assert bool(exported.GetPrimAtPath("/World/envs/env_0/Camera")) is (not suffix) + assert exported.GetPrimAtPath(env_template.format(0) + "/Robot") + assert bool(exported.GetPrimAtPath(env_template.format(0) + "/Camera")) is (not suffix) for env_id in (1, 2): - root = f"/World/envs/env_{env_id}" + root = env_template.format(env_id) assert bool(exported.GetPrimAtPath(root)) is bool(suffix) if suffix: root_prim = exported.GetPrimAtPath(root) diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 9831b5931465..d1ab1374d468 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -75,7 +75,7 @@ def _make_camera_cfg(data_types: list[str]) -> CameraCfg: def _make_ovrtx_camera_render_data() -> OVRTXCameraRenderData: spec = CameraRenderSpec(_make_camera_cfg(["rgb"]), "cpu", 2, ("/World/envs/env_0/Camera",), 2) - return OVRTXCameraRenderData(spec, render_scope_name="RenderCamera_0") + return OVRTXCameraRenderData(spec, "RenderCamera_0", [f"/World/envs/env_{i}/Camera" for i in range(2)]) def _make_ovrtx_renderer_without_backend() -> OVRTXRenderer: @@ -395,26 +395,27 @@ def test_ovrtx_multiple_cameras_render_independent_views(monkeypatch, use_ovstag pytest.skip("OVRTX rendering requires CUDA") SimulationContext.instance().device = "cuda:0" monkeypatch.setenv("ISAAC_LAB_OVRTX_USE_OVSTAGE", str(int(use_ovstage))) + env_template = "/Lab/Cells/Cell{}" if use_ovstage else "/World/envs/env_{}" stage = Usd.Stage.CreateInMemory() UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) UsdGeom.SetStageMetersPerUnit(stage, 1.0) UsdLux.DomeLight.Define(stage, "/World/Light").CreateIntensityAttr(1000.0) - UsdGeom.Xform.Define(stage, "/World/envs/env_0") + UsdGeom.Xform.Define(stage, env_template.format(0)) if not use_ovstage: - UsdGeom.Xform.Define(stage, "/World/envs/env_1") + UsdGeom.Xform.Define(stage, env_template.format(1)) # RTX clips deforming meshes to initial partition bounds; this off-camera wall encloses their motion. # https://docs.omniverse.nvidia.com/materials-and-rendering/latest/cameras.html#limitations - wall = UsdGeom.Cube.Define(stage, "/World/envs/env_0/Wall") + wall = UsdGeom.Cube.Define(stage, env_template.format(0) + "/Wall") wall.CreateSizeAttr(8.0) wall.AddTranslateOp().Set(Gf.Vec3d(10, 0, 3)) batches, initial_points = [], [] for index, x in enumerate((0.0, 2.0)): - camera = UsdGeom.Camera.Define(stage, f"/World/envs/env_0/cam{index}") + camera = UsdGeom.Camera.Define(stage, env_template.format(0) + f"/cam{index}") camera.CreateProjectionAttr("orthographic") camera.CreateHorizontalApertureAttr(20.0) camera.CreateVerticalApertureAttr(20.0) camera.AddTranslateOp().Set(Gf.Vec3d(x, 0, 5)) - path = f"/World/envs/env_0/object{index}" + path = env_template.format(0) + f"/object{index}" if geometry == "rigid": cube = UsdGeom.Cube.Define(stage, path) cube.CreateSizeAttr(1.0) @@ -442,7 +443,15 @@ def test_ovrtx_multiple_cameras_render_independent_views(monkeypatch, use_ovstag initial_points.append(np.asarray(vertices, dtype=np.float32)) source = SceneDataFormat.Points() source.points = wp.array(vertices, dtype=wp.vec3f, device="cuda:0") - batches.append((source, {path.replace("env_0", f"env_{world}"): (0, len(vertices)) for world in range(2)})) + batches.append( + ( + source, + { + path.replace(env_template.format(0), env_template.format(world)): (0, len(vertices)) + for world in range(2) + }, + ) + ) renderer = SimulationContext.instance().get_or_create_backend(OVRTXRendererCfg(async_rendering=asynchronous)) publication = types.SimpleNamespace( @@ -450,10 +459,11 @@ def test_ovrtx_multiple_cameras_render_independent_views(monkeypatch, use_ovstag ) renderer._sdp = SceneDataProvider(publication) plan = make_clone_plan( - (AssetBaseCfg(prim_path="/World/envs/env_[^/]+", cloning_contexts=renderer.cfg.cloning_contexts),), + (AssetBaseCfg(prim_path=env_template.format("[^/]+"), cloning_contexts=renderer.cfg.cloning_contexts),), ((0,),), 2, positions=np.zeros((2, 3), dtype=np.float32), + env_template=env_template, ) sim = SimulationContext.instance() sim.plan = plan @@ -493,7 +503,9 @@ def check_depth(rd, data, expected): device="cuda:0", num_instances=2, view_count=2, - camera_prim_paths=tuple(f"/World/envs/env_{i}/cam{index}" for i in range(2)), + camera_prim_paths=tuple( + env_template.format(i) + f"/cam{index}" for i in range(1 if use_ovstage else 2) + ), ) rd = renderer.create_render_data(spec) data = CameraData.allocate( @@ -1061,7 +1073,7 @@ def test_pose_and_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovs renderer._initialized_scene = True renderer._device = "cpu" renderer.scene.next_camera_id = 0 - renderer.scene.clone_env_paths = ["/World/envs/env_0", "/World/envs/env_1"] + renderer.scene.clone_env_paths = ["/Lab/Cell0", "/Lab/Cell1"] renderer._use_ovstage = use_ovstage renderer.scene.ordinal = 1 renderer.backend.renderer = MagicMock() @@ -1072,10 +1084,12 @@ def test_pose_and_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovs renderer.scene.stage.query_from_path_list.side_effect = lambda paths: contextlib.nullcontext(object()) for name in ("add_usd_reference_from_string", "apply_usd_changes", "remove_usd"): monkeypatch.setattr(ovrtx_renderer_module.ovstage.population, name, MagicMock()) - paths = [ - [f"/World/envs/env_{i}/{name}" for i in range(2)] for name in ("CameraA", "Robot/ee_link/palm_link/CameraB") - ] + paths = [[f"/Lab/Cell{i}/{name}" for i in range(2)] for name in ("CameraA", "Robot/ee_link/palm_link/CameraB")] + paths[0].reverse() + paths[0][0] = "/Lab/Cell1/Robot/CameraA" paths.append(["/World/PerspectiveCamera"]) + paths.append(paths[0][:1]) + renderer.scene.paths.intern_token.side_effect = {"env_0": 10, "env_1": 11}.__getitem__ cameras = [ renderer.create_render_data( CameraRenderSpec( @@ -1086,8 +1100,22 @@ def test_pose_and_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovs camera_prim_paths=camera_paths, ) ) - for index, camera_paths in enumerate((paths[0], paths[1][:1], paths[2])) + for index, camera_paths in enumerate((paths[0], paths[1][1:], paths[2], paths[3])) ] + if use_ovstage: + partitions = [ + call.kwargs["tensors"].tolist() + for call in renderer.scene.stage.write_attribute.call_args_list + if call.args[1] == "omni:scenePartition" + ] + assert partitions == [[11, 10], [10, 11], [11]] + else: + partitions = [ + call.args[2] + for call in renderer.backend.renderer.write_attribute.call_args_list + if len(call.args) > 1 and call.args[1] == "omni:scenePartition" + ] + assert partitions == [["env_1", "env_0"], ["env_0", "env_1"], ["env_1"]] assert not vars(renderer).keys() & {"_camera_xform_binding", "_camera_xform_query", "_camera_paths_list"} monkeypatch.setattr(wp, "get_stream", lambda device: types.SimpleNamespace(cuda_stream=99)) monkeypatch.setattr(wp, "synchronize_stream", lambda stream: None) diff --git a/source/isaaclab_ov/test/test_ovrtx_usd.py b/source/isaaclab_ov/test/test_ovrtx_usd.py index cd2012ae0e9e..522a4d5d4790 100644 --- a/source/isaaclab_ov/test/test_ovrtx_usd.py +++ b/source/isaaclab_ov/test/test_ovrtx_usd.py @@ -389,7 +389,7 @@ def test_export_stage_keeps_all_env_content_when_all_roots_are_sources(): exported = export_stage_to_string( stage, - num_envs, + tuple(f"/World/envs/env_{i}" for i in range(num_envs)), source_paths=tuple(f"/World/envs/env_{env_idx}" for env_idx in range(num_envs)), ) @@ -405,7 +405,7 @@ def test_export_stage_without_clone_destinations(num_envs): exported = export_stage_to_string( stage, - num_envs, + tuple(f"/World/envs/env_{i}" for i in range(num_envs)), source_paths=("/World/envs/env_0",) if num_envs == 1 else ("/World/Shared",), env_paths=env_paths, ) @@ -426,7 +426,7 @@ def test_export_stage_homogeneous_keeps_only_env0_prototype(): exported = export_stage_to_string( stage, - num_envs, + tuple(f"/World/envs/env_{i}" for i in range(num_envs)), source_paths=("/World/envs/env_0",), ) @@ -446,7 +446,7 @@ def test_export_stage_without_keep_env_roots_trims_non_source_env_roots(): exported = export_stage_to_string( stage, - num_envs, + tuple(f"/World/envs/env_{i}" for i in range(num_envs)), source_paths=("/World/envs/env_0",), keep_env_roots=False, ) @@ -474,7 +474,11 @@ def test_export_stage_keeps_file_references_without_exporting_unrouted_copies(tm stage.GetRootLayer().Save() exported = export_stage_to_string( - stage, 2, source_paths=("/World/envs/env_0",), keep_env_roots=False, preserve_references=True + stage, + tuple(f"/World/envs/env_{i}" for i in range(2)), + source_paths=("/World/envs/env_0",), + keep_env_roots=False, + preserve_references=True, ) layer = Sdf.Layer.CreateAnonymous(".usda") assert layer.ImportFromString(exported) @@ -488,7 +492,11 @@ def test_export_stage_keeps_file_references_without_exporting_unrouted_copies(tm dirty_layer = Sdf.Layer.CreateAnonymous(".usda") assert dirty_layer.ImportFromString( export_stage_to_string( - stage, 2, source_paths=("/World/envs/env_0",), keep_env_roots=False, preserve_references=True + stage, + tuple(f"/World/envs/env_{i}" for i in range(2)), + source_paths=("/World/envs/env_0",), + keep_env_roots=False, + preserve_references=True, ) ) dirty_result = Usd.Stage.Open(dirty_layer) @@ -504,7 +512,7 @@ def test_export_stage_heterogeneous_keeps_multiple_sources(): exported = export_stage_to_string( stage, - num_envs, + tuple(f"/World/envs/env_{i}" for i in range(num_envs)), source_paths=("/World/envs/env_0/Object_env0_only", "/World/envs/env_3/Object_env3_only"), ) @@ -531,7 +539,11 @@ def test_export_stage_preserves_authored_scene(settings_path): stage.SetMetadata("renderSettingsPrimPath", settings_path) before = stage.ExportToString() layer = Sdf.Layer.CreateAnonymous() - layer.ImportFromString(export_stage_to_string(stage, num_envs, source_paths=("/World/envs/env_0",))) + layer.ImportFromString( + export_stage_to_string( + stage, tuple(f"/World/envs/env_{i}" for i in range(num_envs)), source_paths=("/World/envs/env_0",) + ) + ) exported = Usd.Stage.Open(layer) assert exported.HasAuthoredMetadata("renderSettingsPrimPath") settings = exported.GetPrimAtPath(exported.GetMetadata("renderSettingsPrimPath")) diff --git a/source/isaaclab_physx/changelog.d/pr10-clone-paths.fixed.rst b/source/isaaclab_physx/changelog.d/pr10-clone-paths.fixed.rst new file mode 100644 index 000000000000..fcf8990f0cd5 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/pr10-clone-paths.fixed.rst @@ -0,0 +1,2 @@ +* Fixed Isaac RTX environment partitioning to use the clone plan's declared namespace. +* Fixed frame-transformer row ordering and source exclusion during cloning for custom namespaces. diff --git a/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py b/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py index d059d965f1e9..6e291c57bf72 100644 --- a/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py +++ b/source/isaaclab_physx/isaaclab_physx/cloner/replicate.py @@ -129,7 +129,7 @@ def _replicate_instances( excluded_paths = ( list(dict.fromkeys(native_paths)) if has_usd_only_sources - else list(dict.fromkeys(("/World/template", *env_namespaces))) + else list(dict.fromkeys((*[source for source, _, _ in physx_queue], *env_namespaces))) ) def attach_fn(_stage_id: int): diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index d1d5f76d38c9..17eabf00a1ab 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -219,7 +219,7 @@ def prepare_stage(self, stage: Usd.Stage, num_envs: int) -> None: When disabled, this method is a no-op and writes no ``primvars:omni:scenePartition`` or ``omni:scenePartition`` attributes. - When enabled, for each ``/World/envs/env_{i}`` root, writes the inheriting primvar + When enabled, for each environment root declared by the clone plan, writes the inheriting primvar ``primvars:omni:scenePartition`` (token ``env_{i}``) on the root and the matching non-primvar ``omni:scenePartition`` token on every :class:`UsdGeom.Camera` descendant. RTX honors primvar inheritance, so the env-root primvar propagates to all descendant @@ -238,7 +238,7 @@ def prepare_stage(self, stage: Usd.Stage, num_envs: int) -> None: token_type = Sdf.ValueTypeNames.Token with Sdf.ChangeBlock(): for env_idx in range(num_envs): - env_prim = stage.GetPrimAtPath(f"/World/envs/env_{env_idx}") + env_prim = stage.GetPrimAtPath(self._sim.get_clone_plan().env_template.format(env_idx)) if not env_prim.IsValid(): continue token = f"env_{env_idx}" diff --git a/source/isaaclab_physx/isaaclab_physx/sensors/frame_transformer/frame_transformer.py b/source/isaaclab_physx/isaaclab_physx/sensors/frame_transformer/frame_transformer.py index 657bf3428314..be5d3f0989e7 100644 --- a/source/isaaclab_physx/isaaclab_physx/sensors/frame_transformer/frame_transformer.py +++ b/source/isaaclab_physx/isaaclab_physx/sensors/frame_transformer/frame_transformer.py @@ -6,7 +6,6 @@ from __future__ import annotations import logging -import re import warnings from typing import TYPE_CHECKING @@ -15,6 +14,7 @@ from pxr import UsdPhysics +from isaaclab import cloner from isaaclab.markers import VisualizationMarkers from isaaclab.sensors.frame_transformer import BaseFrameTransformer from isaaclab.sim.utils.queries import path_expr_to_glob, resolve_matching_prims_from_source @@ -233,12 +233,7 @@ def _initialize_impl(self): tracked_prim_paths = [body_names_to_frames[body_name]["prim_path"] for body_name in body_names_to_frames.keys()] tracked_body_names = [body_name for body_name in body_names_to_frames.keys()] - # Convert each tracked prim path to PhysX glob form for ``create_rigid_body_view``. - # Plan-mode dest expressions use ``env_.*`` (regex), legacy mode produces concrete - # ``env_0`` paths; chain both substitutions so each mode normalises to ``env_*``. - body_names_regex = [ - path_expr_to_glob(tracked_prim_path).replace("env_0", "env_*") for tracked_prim_path in tracked_prim_paths - ] + body_names_regex = [path_expr_to_glob(path) for path in tracked_prim_paths] # obtain global simulation view self._physics_sim_view = self._backend_manager.get_physics_sim_view() @@ -251,40 +246,13 @@ def _initialize_impl(self): # by frame name correctly all_prim_paths = self._frame_physx_view.prim_paths - if "env_" in all_prim_paths[0]: - - def extract_env_num_and_prim_path(item: str) -> tuple[int, str]: - """Separates the environment number and prim_path from the item. - - Args: - item: The item to extract the environment number from. Assumes item is of the form - `/World/envs/env_1/blah` or `/World/envs/env_11/blah`. - Returns: - The environment number and the prim_path. - """ - match = re.search(r"env_(\d+)(.*)", item) - return (int(match.group(1)), match.group(2)) - - # Find the indices that would reorganize output to be per environment. - # We want `env_1/blah` to come before `env_11/blah` and env_1/Robot/base - # to come before env_1/Robot/foot so we need to use custom key function - self._per_env_indices = [ - index - for index, _ in sorted( - list(enumerate(all_prim_paths)), key=lambda x: extract_env_num_and_prim_path(x[1]) - ) - ] - - # Only need 0th env as the names and their ordering are the same across environments - sorted_prim_paths = [ - all_prim_paths[index] for index in self._per_env_indices if "env_0" in all_prim_paths[index] - ] - - else: - # If no environment is present, then the order of the body names is the same as the order of the - # prim paths sorted alphabetically - self._per_env_indices = [index for index, _ in sorted(enumerate(all_prim_paths), key=lambda x: x[1])] - sorted_prim_paths = [all_prim_paths[index] for index in self._per_env_indices] + plan = self._sim.get_clone_plan() + order = [] + for index, prim_path in enumerate(all_prim_paths): + match = cloner.path.match(prim_path, plan.env_template) if plan is not None else None + order.append((int(match.instance), match.suffix, index) if match else (-1, prim_path, index)) + self._per_env_indices = [index for _, _, index in sorted(order)] + sorted_prim_paths = [all_prim_paths[index] for index in self._per_env_indices[: len(tracked_body_names)]] # -- target frames: use relative prim path for unique identification self._target_frame_body_names = [self._get_relative_body_path(prim_path) for prim_path in sorted_prim_paths] @@ -578,28 +546,8 @@ def _get_connecting_lines( return positions, orientations, lengths - @staticmethod - def _get_relative_body_path(prim_path: str) -> str: - """Extract a normalized body path from a prim path. - - Removes the environment instance segment `/envs/env_/` to normalize paths - across multiple environments, while preserving the `/envs/` prefix to - distinguish environment-scoped paths from non-environment paths. - - Examples: - - '/World/envs/env_0/Robot/torso' -> '/World/envs/Robot/torso' - - '/World/envs/env_123/Robot/left_hand' -> '/World/envs/Robot/left_hand' - - '/World/Robot' -> '/World/Robot' - - '/World/Robot_2/left_hand' -> '/World/Robot_2/left_hand' - - Args: - prim_path: The full prim path. - - Returns: - The prim path with `/envs/env_/` removed, preserving `/envs/`. - """ - # the input may be a concrete path or an expression, so the environment segment can be a - # concrete id or a wildcard standing for one; a wildcard written as a character class holds - # a '/' that is not a separator and would otherwise be split by the one-segment alternative - pattern = re.compile(r"/envs/env_(?:\[\^/\][*+]|\.\*|[^/]+)/") - return pattern.sub("/envs/", prim_path) + def _get_relative_body_path(self, prim_path: str) -> str: + """Use the same body key for declared expressions and concrete native copies.""" + plan = self._sim.get_clone_plan() + match = cloner.path.match(prim_path, plan.env_template) if plan is not None else None + return plan.env_template.format("[^/]+") + match.suffix if match is not None else prim_path diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py index 6ab225513068..ddc75c123a19 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_scene_partitioning.py @@ -30,6 +30,7 @@ launch_test_simulation(enable_cameras=True) import os +from types import SimpleNamespace import pytest import torch @@ -40,6 +41,7 @@ import isaaclab.sim as sim_utils from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg +from isaaclab.cloner import CloneCfg from isaaclab.markers import VisualizationMarkers, VisualizationMarkersCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg from isaaclab.sensors.camera import CameraCfg @@ -80,18 +82,20 @@ def test_partitioning_cfg_overrides_legacy_environment_variable( monkeypatch.setenv(_ENV_VAR, environment_value) stage = Usd.Stage.CreateInMemory() - world = stage.DefinePrim("/World", "Xform") # noqa: F841 - env0 = stage.DefinePrim("/World/envs/env_0", "Xform") # noqa: F841 + plan = SimpleNamespace(env_template="/Lab/Cell{}") + prim = stage.DefinePrim(plan.env_template.format(0), "Xform") + camera = stage.DefinePrim(f"{prim.GetPath()}/Camera", "Camera") renderer = object.__new__(IsaacRtxRenderer) + renderer._sim = SimpleNamespace(get_clone_plan=lambda: plan) if cfg_enabled is None: renderer.cfg = IsaacRtxRendererCfg() else: renderer.cfg = IsaacRtxRendererCfg(enable_scene_partitioning=cfg_enabled) renderer.prepare_stage(stage, num_envs=1) - prim = stage.GetPrimAtPath("/World/envs/env_0") assert prim.HasAttribute("primvars:omni:scenePartition") is expected + assert camera.HasAttribute("omni:scenePartition") is expected @pytest.mark.isaacsim_ci @@ -132,7 +136,7 @@ class _Scene(InteractiveSceneCfg): with build_simulation_context(device="cuda:0", dt=1.0 / 60.0) as sim: sim._app_control_on_stop_handle = None - scene = InteractiveScene(_Scene(num_envs=4, env_spacing=0.0)) + scene = InteractiveScene(_Scene(num_envs=4, env_spacing=0.0, clone_cfg=CloneCfg(clone_template="/Lab/Cell{}"))) sim.reset() # one settle step so RigidObject data buffers are populated before we write into them sim.step() diff --git a/source/isaaclab_physx/test/sensors/test_frame_transformer.py b/source/isaaclab_physx/test/sensors/test_frame_transformer.py index bdf73d98eea4..7ba9a23d3de4 100644 --- a/source/isaaclab_physx/test/sensors/test_frame_transformer.py +++ b/source/isaaclab_physx/test/sensors/test_frame_transformer.py @@ -421,11 +421,12 @@ class MultiRobotSceneCfg(InteractiveSceneCfg): frame_transformer: FrameTransformerCfg = None # type: ignore # Use multiple envs for env patterns, single env for direct paths - num_envs = 2 if path_prefix == "{ENV_REGEX_NS}" else 1 + num_envs = 12 if path_prefix == "{ENV_REGEX_NS}" else 1 env_spacing = 10.0 if path_prefix == "{ENV_REGEX_NS}" else 0.0 # Create scene config with appropriate prim paths scene_cfg = MultiRobotSceneCfg(num_envs=num_envs, env_spacing=env_spacing, lazy_sensor_update=False) + scene_cfg.clone_cfg.clone_template = "/Lab/Cells/Cell{}" scene_cfg.robot = replace(ANYMAL_C_CFG, prim_path=f"{path_prefix}/Robot") scene_cfg.robot_1 = replace( ANYMAL_C_CFG, diff --git a/source/isaaclab_physx/test/sim/test_cloner.py b/source/isaaclab_physx/test/sim/test_cloner.py index d13718d49fd5..572d5833fd08 100644 --- a/source/isaaclab_physx/test/sim/test_cloner.py +++ b/source/isaaclab_physx/test/sim/test_cloner.py @@ -17,6 +17,8 @@ from isaaclab_physx.cloner import PhysxReplicateContext, physx_replicate from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg +from pxr import Sdf + import isaaclab.sim as sim_utils from isaaclab.cloner import ( disabled_fabric_change_notifies, @@ -136,7 +138,7 @@ def test_physx_replicate_context_consumes_plan(sim, world_prototypes): [ (3, "/World/envs/env_0", [2]), (1, "/World/envs/env_0", []), - (3, "/World/template/Robot", [3]), + (3, "/Library/Robot", [3]), ], ) def test_physx_replicate_world_counts(sim, num_envs, src, expected_worlds): @@ -146,15 +148,14 @@ def test_physx_replicate_world_counts(sim, num_envs, src, expected_worlds): from the replication targets when it also maps to other environments. A source at ``env_0`` mapping to ``[0, 1, 2]`` only replicates to ``[1, 2]`` (2 worlds). With ``num_envs == 1`` the ``num_envs > 1`` guard skips registration entirely. - Non-env sources (e.g. ``/World/template/Robot``) are never excluded because the + Non-env sources (e.g. ``/Library/Robot``) are never excluded because the self-id is not a digit. """ from unittest.mock import patch stage = sim_utils.get_current_stage() sim_utils.create_prim("/World/envs", "Xform") - sim_utils.create_prim("/World/template", "Xform") - sim_utils.create_prim("/World/template/Robot", "Xform") + sim_utils.create_prim("/Library/Robot", "Xform") for i in range(num_envs): sim_utils.create_prim(f"/World/envs/env_{i}", "Xform") @@ -172,6 +173,10 @@ def test_physx_replicate_world_counts(sim, num_envs, src, expected_worlds): f"Expected replicate world counts {expected_worlds}, got {replicate_calls}" ) + if expected_worlds: + attach = mock_rep.register_replicator.call_args.args[1] + assert any(Sdf.Path(src).HasPrefix(path) for path in attach(0)) + def test_physx_replicate_isolated_source_loaded_without_replication(sim): """A single-env source (worlds=[self]) is correctly loaded after physx_replicate. @@ -255,10 +260,8 @@ def test_physx_replicate_heterogeneous_isolated_sources(sim): ] assert replicate_calls == expected, f"Expected {expected}, got {replicate_calls}." - # attach_fn always returns ["/World/template", "/World/envs"] so the replicator - # owns all env prims. Self-only sources get their physics body from the - # rep.replicate() call itself. - assert "/World/template" in attach_excluded + # Sources and destinations are owned by native replication, not the initial stage import. + assert all(source in attach_excluded for source, _ in expected) assert "/World/envs" in attach_excluded diff --git a/source/isaaclab_tasks/changelog.d/pr10-clone-paths.fixed.rst b/source/isaaclab_tasks/changelog.d/pr10-clone-paths.fixed.rst new file mode 100644 index 000000000000..9c2cb716cf91 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/pr10-clone-paths.fixed.rst @@ -0,0 +1,2 @@ +* Fixed task asset lookups, coupled solver selectors, and teleoperation paths to follow the + scene's clone template instead of assuming the default environment namespace. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/mdp/rewards.py index 36a7eab45f7d..06ed9e0f9cf7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/assemble_trocar/mdp/rewards.py @@ -303,7 +303,7 @@ def get_trocar_tip_position( # Cache the tip offset to avoid recalculating every step. # The local offset from root to tip is a static geometric property of the USD - # asset and is identical across all replicated envs. We read it once from env_0's + # asset and is identical across all replicated envs. We read it once from the authored prototype's # USD prim, then apply it per-env at runtime using each env's dynamic root pose. s = get_assemble_trocar_state(env) cache_attr = f"tip_offset_{asset_cfg.name}" @@ -312,12 +312,11 @@ def get_trocar_tip_position( if tip_offset_local is None: usd_stage = env.scene.stage + root_path = env.scene[asset_cfg.name].cfg.spawn.spawn_path if asset_cfg.name == "trocar_1": - tip_path = "/World/envs/env_0/trocar_1/Trocar002/White_pos" - root_path = "/World/envs/env_0/trocar_1" + tip_path = f"{root_path}/Trocar002/White_pos" elif asset_cfg.name == "trocar_2": - tip_path = "/World/envs/env_0/trocar_2/DisposableLaparoscopicPunctureDevice001/Red_pos" - root_path = "/World/envs/env_0/trocar_2" + tip_path = f"{root_path}/DisposableLaparoscopicPunctureDevice001/Red_pos" else: raise ValueError(f"Invalid asset configuration: {asset_cfg.name}") diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env.py index 7c7cb5ec8c5c..e787412dfcca 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env.py @@ -143,7 +143,7 @@ def _prepare_newton_extras(self, cfg: FrankaPourResetDatasetEnvCfg) -> None: def _add_pour_world_to_builder(self, builder, env_id: int, position, quaternion) -> None: """Add only solver-specific collision representations to one imported scene world.""" builder.particle_max_velocity = self._particle_max_velocity - env_root = f"/World/envs/env_{env_id}" + env_root = self.cfg.scene.clone_cfg.clone_template.format(env_id) body_ids = self._current_world_range(builder, "body", env_id) shape_ids = self._current_world_range(builder, "shape", env_id) self._disable_robot_particle_collision(builder, body_ids, shape_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py index 221daf2de991..9b8c1e399bf5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py @@ -612,9 +612,9 @@ def __post_init__(self): nconmax=400, ), bodies=[ - r"/World/envs/env_.*/Robot", - r"/World/envs/env_.*/SourceCup", - r"/World/envs/env_.*/TargetCup", + r"{ENV_REGEX_NS}/Robot", + r"{ENV_REGEX_NS}/SourceCup", + r"{ENV_REGEX_NS}/TargetCup", ], include_static_shapes=True, substeps=3, @@ -650,7 +650,7 @@ def __post_init__(self): CouplerProxyMappingCfg( source=RIGID_ENTRY, destination=MPM_ENTRY, - bodies=[r"/World/envs/env_.*/SourceCup", r"/World/envs/env_.*/TargetCup"], + bodies=[r"{ENV_REGEX_NS}/SourceCup", r"{ENV_REGEX_NS}/TargetCup"], # Represent the supported or grasp-constrained 50 g cup as 50 kg only in # MPM's proxy view so the roughly 140 g payload cannot create unphysical recoil. mass_scale=1000.0, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/g1_terrain/granular.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/g1_terrain/granular.py index f2f1f9267a16..91d6dc092898 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/g1_terrain/granular.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/g1_terrain/granular.py @@ -64,7 +64,7 @@ def configure_granular(cfg, is_physx: bool): cfg.sim.device = "cpu" cfg.scene.terrain.class_type = ParticleTerrainImporter return - robot_pattern = r"/World/envs/env_.*/Robot" + robot_pattern = r"{ENV_REGEX_NS}/Robot" coupled = CouplerProxyCfg( entries=[ CouplerEntryCfg( diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/g1_terrain/terrain_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/g1_terrain/terrain_env.py index 2be9d93927ab..7c8093e4e254 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/g1_terrain/terrain_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/g1_terrain/terrain_env.py @@ -85,7 +85,7 @@ def prepare_view(self, kit_visualizer: KitVisualizer | None = None) -> None: "left_ankle_roll_link", "right_ankle_roll_link", } - for prim in Usd.PrimRange(stage.GetPrimAtPath("/World/envs/env_0/Robot")): + for prim in Usd.PrimRange(stage.GetPrimAtPath(self.scene["robot"].cfg.spawn.spawn_path)): if prim.HasAPI(UsdPhysics.CollisionAPI) or prim.GetName().startswith("imu_in_"): UsdGeom.Imageable(prim).MakeInvisible() if prim.GetName() == "visuals": diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/gb300_sharpa_insertion/env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/gb300_sharpa_insertion/env_cfg.py index 13815c3e5a9f..a7c8c368786a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/gb300_sharpa_insertion/env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/gb300_sharpa_insertion/env_cfg.py @@ -186,7 +186,7 @@ def __post_init__(self) -> None: entries=[ CouplerEntryCfg( name="rigid", - bodies=[r"/World/envs/env_.*/Robot", r"/World/envs/env_.*/Fixtures"], + bodies=[r"{ENV_REGEX_NS}/Robot", r"{ENV_REGEX_NS}/Fixtures"], substeps=1, solver_cfg=MJWarpSolverCfg( cone="elliptic", @@ -200,7 +200,7 @@ def __post_init__(self) -> None: ), CouplerEntryCfg( name="cable", - bodies=[r"/World/envs/env_.*/Cable"], + bodies=[r"{ENV_REGEX_NS}/Cable"], substeps=1, include_static_shapes=True, solver_cfg=VBDSolverCfg( diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/gb300_sharpa_insertion/hydroelastic.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/gb300_sharpa_insertion/hydroelastic.py index 4fb94bb7e911..795abc768eb8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/gb300_sharpa_insertion/hydroelastic.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/gb300_sharpa_insertion/hydroelastic.py @@ -29,8 +29,8 @@ def configure_hydroelastic_physics(physics: NewtonCfg) -> None: physics: Newton configuration whose entries own the rigid scene and cable. """ rigid, cable = physics.solver_cfg.entries - rigid.bodies.extend((r"/World/envs/env_.*/Cable/head", r"/World/envs/env_.*/Cable/latch")) - cable.bodies = [r"/World/envs/env_.*/Cable/(?!(?:head|latch)(?:/|$)).*"] + rigid.bodies.extend((r"{ENV_REGEX_NS}/Cable/head", r"{ENV_REGEX_NS}/Cable/latch")) + cable.bodies = [r"{ENV_REGEX_NS}/Cable/(?!(?:head|latch)(?:/|$)).*"] # The fixture proxy includes the pedestal and rack; only the ground needs static ownership. cable.include_static_shapes = False cable.shape_label_patterns = [r"/World/GroundPlane/.*"] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/keyboard/mdp/commands/typing_commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/keyboard/mdp/commands/typing_commands.py index bf91a014ce29..9f7cd7a27e64 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/keyboard/mdp/commands/typing_commands.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/keyboard/mdp/commands/typing_commands.py @@ -17,6 +17,7 @@ import torch import warp as wp +from isaaclab import cloner from isaaclab.controllers.differential_ik import DifferentialIKController from isaaclab.managers import CommandTerm, ManagerTermBase from isaaclab.utils import index_fill_ @@ -168,10 +169,10 @@ def __init__(self, cfg: LetterTypingCommandCfg, env: ManagerBasedRLEnv): num_instances = len(prim_paths) env_part = [] for path in prim_paths: - env_match = re.search(r"/env_([0-9]+)", path) + env_match = cloner.path.match(path, env.scene.cloner_cfg.clone_template) part_match = re.search(r"/part_([0-9]+)", path) assert env_match is not None, f"{path!r} is not inside a numbered environment" - env_part.append((int(env_match.group(1)), int(part_match.group(1)) if part_match else 0)) + env_part.append((int(env_match.instance), int(part_match.group(1)) if part_match else 0)) else: num_instances = int(view.count) per_world = max(num_instances // self.num_envs, 1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/locomanip_pick_place/locomanipulation_g1_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/locomanip_pick_place/locomanipulation_g1_env_cfg.py index 23404da5ff32..3b8952eadb8d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/locomanip_pick_place/locomanipulation_g1_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/locomanip_pick_place/locomanipulation_g1_env_cfg.py @@ -475,7 +475,7 @@ def __post_init__(self): anchor_pos=(0.0, 0.0, -0.95), anchor_rot=(0.0, 0.0, 0.0, 1.0), ) - self.xr.anchor_prim_path = "/World/envs/env_0/Robot/pelvis" + self.xr.anchor_prim_path = "{ENV_REGEX_NS}/Robot/pelvis" self.xr.fixed_anchor_height = True self.xr.anchor_rotation_mode = XrAnchorRotationMode.FOLLOW_PRIM_SMOOTHED diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/factory_assets_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/factory_assets_cfg.py index 8acde47f48d7..64575e76b366 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/factory_assets_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/factory_assets_cfg.py @@ -146,7 +146,7 @@ class _PlugCollisionPropsCfg(PresetCfg): ) FRANKA_PANDA_PHYSX_CFG = ArticulationCfg( - prim_path="/World/envs/env_.*/Robot", + prim_path="{ENV_REGEX_NS}/Robot", spawn=sim_utils.UsdFileCfg( usd_path=f"{ASSET_DIR}/franka_mimic.usd", activate_contact_sensors=True, @@ -167,7 +167,7 @@ class _PlugCollisionPropsCfg(PresetCfg): FRANKA_PANDA_NEWTON_CFG = ArticulationCfg( - prim_path="/World/envs/env_.*/Robot", + prim_path="{ENV_REGEX_NS}/Robot", spawn=sim_utils.UsdFileCfg( usd_path=f"{ASSET_DIR}/franka_mimic.usd", activate_contact_sensors=True, @@ -212,7 +212,7 @@ class _PlugCollisionPropsCfg(PresetCfg): def _assembly_asset_cfg(name: str, usd_file: str, mass: float, *, is_socket: bool) -> RigidObjectCfg: """Create one assembly asset from the shared socket or plug physics policy.""" return RigidObjectCfg( - prim_path=f"/World/envs/env_.*/{name}", + prim_path=f"{{ENV_REGEX_NS}}/{name}", spawn=sim_utils.UsdFileCfg( usd_path=f"{ASSET_DIR}/NIST/{usd_file}", rigid_props=ASSEMBLY_SOCKET_RIGID_BODY_PROPS_CFG if is_socket else ASSEMBLY_PLUG_RIGID_BODY_PROPS_CFG, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/factory_scenes_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/factory_scenes_cfg.py index 7ab6f58bc406..b001a5ab433f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/factory_scenes_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/nist/factory_scenes_cfg.py @@ -12,13 +12,11 @@ from isaaclab.assets import ArticulationCfg, RigidObjectCfg from isaaclab.scene import InteractiveSceneCfg -from isaaclab.utils import configclass, replace +from isaaclab.utils import configclass from isaaclab_tasks.contrib.nist import factory_assets_cfg as assets from isaaclab_tasks.utils import PresetCfg, preset -_FRANKA_PANDA_PHYSX_CFG = replace(assets.FRANKA_PANDA_PHYSX_CFG, prim_path="{ENV_REGEX_NS}/Robot") - @configclass class FactorySceneBase(InteractiveSceneCfg): @@ -29,10 +27,10 @@ class FactorySceneBase(InteractiveSceneCfg): table = assets.TABLE_CFG nistboard = assets.NISTBOARD_CFG robot: ArticulationCfg = preset( # type: ignore[assignment] - default=_FRANKA_PANDA_PHYSX_CFG, - isaacsim_physx=_FRANKA_PANDA_PHYSX_CFG, - physx=_FRANKA_PANDA_PHYSX_CFG, - newton_mjwarp=replace(assets.FRANKA_PANDA_NEWTON_CFG, prim_path="{ENV_REGEX_NS}/Robot"), + default=assets.FRANKA_PANDA_PHYSX_CFG, + isaacsim_physx=assets.FRANKA_PANDA_PHYSX_CFG, + physx=assets.FRANKA_PANDA_PHYSX_CFG, + newton_mjwarp=assets.FRANKA_PANDA_NEWTON_CFG, ) dome_light = assets.DOMELIGHT_CFG diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/attachment.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/attachment.py index 4bdc4ae553d0..fa1750745381 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/attachment.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/attachment.py @@ -46,17 +46,17 @@ def __init__( palm_pattern = re.compile(robot.cfg.prim_path + "/" + re.escape(PALM_BODY_NAME)) spoon_pattern = re.compile(spoon.cfg.prim_path) palm_ids, spoon_ids = [], [] + body_world = self._model.body_world.numpy() for env_index in range(robot.num_instances): - environment = f"/env_{env_index}/" palms = [ index for index, label in enumerate(self._model.body_label) - if environment in label and palm_pattern.fullmatch(label) + if body_world[index] == env_index and palm_pattern.fullmatch(label) ] spoons = [ index for index, label in enumerate(self._model.body_label) - if environment in label and spoon_pattern.fullmatch(label) + if body_world[index] == env_index and spoon_pattern.fullmatch(label) ] if len(palms) != 1 or len(spoons) != 1: raise ValueError(f"Expected one native palm and spoon in env_{env_index}; got {palms}, {spoons}.") diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/demo_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/demo_env.py index 1276e8c53c02..426b9e7f092b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/demo_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/demo_env.py @@ -129,9 +129,8 @@ def _configure_colliders(self, builder, env_id: int, _position, _quaternion) -> # Keep advection inside the native collider query band at coarse timesteps. material_dt = self.cfg.sim.dt / self.cfg.sim.physics.solver_cfg.entries[1].substeps builder.particle_max_velocity = min(4.0, 0.5 * self.cfg.voxel_size / material_dt) - prefix = f"/World/envs/env_{env_id}/" for index, label in enumerate(builder.shape_label): - if not str(label).startswith(prefix): + if builder.shape_world[index] != env_id: continue if "/Robot/" in str(label): builder.shape_flags[index] &= ~int(newton.ShapeFlags.COLLIDE_PARTICLES) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/demo_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/demo_env_cfg.py index 9530d932d1f9..10d2fddff286 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/demo_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/demo_env_cfg.py @@ -67,7 +67,7 @@ def make_physics_cfg(voxel_size: float) -> NewtonCfg: solver_cfg=MJWarpSolverCfg( use_mujoco_contacts=False, integrator="implicitfast", njmax=1024, nconmax=128 ), - bodies=[r"/World/envs/env_.*/Robot"], + bodies=[r"{ENV_REGEX_NS}/Robot"], ), CouplerEntryCfg( name="media", @@ -87,7 +87,7 @@ def make_physics_cfg(voxel_size: float) -> NewtonCfg: air_drag=1.0, separate_worlds=True, ), - bodies=[r"/World/envs/env_.*/Spoon"], + bodies=[r"{ENV_REGEX_NS}/Spoon"], all_particles=True, include_static_shapes=True, in_place=True, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/rendering.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/rendering.py index 297ba39df9cf..0b2be11c7877 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/rendering.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/rizon_sharpa_coffee/rendering.py @@ -488,7 +488,7 @@ def spawn_presentation(prim_path: str, cfg: CoffeePresentationCfg, translation=N kitchen_cfg = sim_utils.UsdFileCfg(usd_path=f"{COFFEE_ASSET_DIR}/kitchen.usda") kitchen_cfg.func(kitchen_path, kitchen_cfg) for prim in stage.Traverse(): - if prim.GetPath().HasPrefix("/World/envs") and prim.GetName() == "StudioFloor": + if prim.GetName() == "StudioFloor": UsdGeom.Imageable(prim).MakeInvisible() return root diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/shoelace/shoelace_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/shoelace/shoelace_env_cfg.py index 221adcc659b0..ee1bc68bbc0e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/shoelace/shoelace_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/shoelace/shoelace_env_cfg.py @@ -342,7 +342,7 @@ class ShoelaceEnvCfg(ManagerBasedRLEnvCfg): njmax=2048, nconmax=256, ), - bodies=[r"/World/envs/env_[^/]+/(Robot(Left|Right)|ShoelaceScene/Shoe)"], + bodies=[r"{ENV_REGEX_NS}/(Robot(Left|Right)|ShoelaceScene/Shoe)"], ), CouplerEntryCfg( name="shoelace", @@ -351,7 +351,7 @@ class ShoelaceEnvCfg(ManagerBasedRLEnvCfg): rigid_compliant_alm=True, rigid_body_contact_buffer_size=physics.VBD_CONTACT_BUFFER, ), - bodies=[r"/World/envs/env_[^/]+/ShoelaceScene/Shoelace(Left|Right)"], + bodies=[r"{ENV_REGEX_NS}/ShoelaceScene/Shoelace(Left|Right)"], include_static_shapes=True, ), ], diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/shoelace/shoelace_physics.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/shoelace/shoelace_physics.py index e470ed7a4114..ddb60c829dc2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/shoelace/shoelace_physics.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/shoelace/shoelace_physics.py @@ -85,7 +85,7 @@ def configure_shoelace_builder( for side, free_end_at_start in (("Left", True), ("Right", False)): suffix = f"/ShoelaceScene/Shoelace{side}/geometry/mesh" - curve = stage.GetPrimAtPath(f"/World/envs/env_0{suffix}") + curve = stage.GetPrimAtPath(cfg.scene.clone_cfg.clone_template.format(0) + suffix) material, _ = UsdShade.MaterialBindingAPI(curve).ComputeBoundMaterial(materialPurpose="physics") friction = UsdPhysics.MaterialAPI(material.GetPrim()).GetDynamicFrictionAttr().Get() # Numeric suffixes recover curve order; lexical sorting would put segment 10 before segment 2. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py index 9b7e8a9c106a..3af7043b6252 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/stack/config/so101/stack_ik_abs_env_cfg.py @@ -297,6 +297,6 @@ def __post_init__(self): # also uses ``Robot/base``). The clutch's reset-origin home (_BASE_T_GRIPPER_HOME) # provides the engage seed, so no live end-effector feed is needed. Teleop runs a # single env (env_0). - target_frame_prim_path="/World/envs/env_0/Robot/base", + target_frame_prim_path="{ENV_REGEX_NS}/Robot/base", **({"xr_cfg": self.xr} if self.xr is not None else {}), ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/ur10_particle_push/ur10_particle_push_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/ur10_particle_push/ur10_particle_push_env_cfg.py index 5d63b7ea27b3..6d41a1f5c396 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/ur10_particle_push/ur10_particle_push_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/ur10_particle_push/ur10_particle_push_env_cfg.py @@ -978,7 +978,7 @@ def __post_init__(self) -> None: njmax=256, nconmax=512, ), - bodies=[r"/World/envs/env_.*/Robot", r"/World/envs/env_.*/Table"], + bodies=[r"{ENV_REGEX_NS}/Robot", r"{ENV_REGEX_NS}/Table"], # Let Newton derive body and global static shape visibility without explicit ownership. include_body_shapes=False, # Refine rigid integration with three substeps per coupled interval. @@ -1004,12 +1004,12 @@ def __post_init__(self) -> None: project_outside_colliders=False, ), bodies=[ - r"/World/envs/env_.*/MPMWorkSurface", - r"/World/envs/env_.*/MPMBinFloor", - r"/World/envs/env_.*/MPMBinFront", - r"/World/envs/env_.*/MPMBinBack", - r"/World/envs/env_.*/MPMBinLeft", - r"/World/envs/env_.*/MPMBinRight", + r"{ENV_REGEX_NS}/MPMWorkSurface", + r"{ENV_REGEX_NS}/MPMBinFloor", + r"{ENV_REGEX_NS}/MPMBinFront", + r"{ENV_REGEX_NS}/MPMBinBack", + r"{ENV_REGEX_NS}/MPMBinLeft", + r"{ENV_REGEX_NS}/MPMBinRight", ], shape_label_patterns=[GROUND_PLANE_SHAPE_PATTERN], all_particles=True, @@ -1024,7 +1024,7 @@ def __post_init__(self) -> None: CouplerProxyMappingCfg( source=RIGID_ENTRY, destination=MPM_ENTRY, - bodies=[r"/World/envs/env_.*/Robot/ee_link/Paddle"], + bodies=[r"{ENV_REGEX_NS}/Robot/ee_link/Paddle"], mode="lagged", mass_scale=self.proxy_mass_scale, collision_pipeline=None, diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_cable_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_cable_env_cfg.py index 21d55a800aad..15aa2bf50805 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_cable_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_cable_env_cfg.py @@ -51,12 +51,12 @@ class PhysicsCfg(PresetCfg): ls_iterations=50, integrator="implicitfast", ), - bodies=[r"/World/envs/env_.*/Robot"], + bodies=[r"{ENV_REGEX_NS}/Robot"], ), CouplerEntryCfg( name="cable", solver_cfg=VBDSolverCfg(iterations=10), - bodies=[r"/World/envs/env_.*/Cable"], + bodies=[r"{ENV_REGEX_NS}/Cable"], include_static_shapes=True, ), ], @@ -65,8 +65,8 @@ class PhysicsCfg(PresetCfg): source="rigid", destination="cable", bodies=[ - r"/World/envs/env_.*/Robot/Geometry/.*panda_hand", - r"/World/envs/env_.*/Robot/Geometry/.*panda_(left|right)finger", + r"{ENV_REGEX_NS}/Robot/Geometry/.*panda_hand", + r"{ENV_REGEX_NS}/Robot/Geometry/.*panda_(left|right)finger", ], collide_interval=1, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_cloth_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_cloth_env_cfg.py index c76eff81eb67..fae40e3c80e1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_cloth_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_cloth_env_cfg.py @@ -56,7 +56,7 @@ class PhysicsCfg(PresetCfg): ls_iterations=50, integrator="implicitfast", ), - bodies=[r"/World/envs/env_[^/]+/Robot", r"/World/envs/env_[^/]+/Support(Neg|Pos)Y"], + bodies=[r"{ENV_REGEX_NS}/Robot", r"{ENV_REGEX_NS}/Support(Neg|Pos)Y"], ), CouplerEntryCfg( name="soft", @@ -70,9 +70,9 @@ class PhysicsCfg(PresetCfg): source="rigid", destination="soft", bodies=[ - r"/World/envs/env_[^/]+/Robot/Geometry/.*panda_hand", - r"/World/envs/env_[^/]+/Robot/Geometry/.*panda_(left|right)finger", - r"/World/envs/env_[^/]+/Support(Neg|Pos)Y", + r"{ENV_REGEX_NS}/Robot/Geometry/.*panda_hand", + r"{ENV_REGEX_NS}/Robot/Geometry/.*panda_(left|right)finger", + r"{ENV_REGEX_NS}/Support(Neg|Pos)Y", ], collide_interval=1, collision_pipeline=NewtonCollisionPipelineCfg( diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_soft_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_soft_env_cfg.py index 663bfadc9e5d..a1153b5807cd 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_soft_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka_soft/franka_soft_env_cfg.py @@ -140,7 +140,7 @@ class PhysicsCfg(PresetCfg): ls_iterations=50, integrator="implicitfast", ), - bodies=[r"/World/envs/env_[^/]+/Robot"], + bodies=[r"{ENV_REGEX_NS}/Robot"], ), CouplerEntryCfg( name="soft", @@ -154,8 +154,8 @@ class PhysicsCfg(PresetCfg): source="rigid", destination="soft", bodies=[ - r"/World/envs/env_[^/]+/Robot/Geometry/.*panda_hand", - r"/World/envs/env_[^/]+/Robot/Geometry/.*panda_(left|right)finger", + r"{ENV_REGEX_NS}/Robot/Geometry/.*panda_hand", + r"{ENV_REGEX_NS}/Robot/Geometry/.*panda_(left|right)finger", ], collide_interval=1, collision_pipeline=NewtonCollisionPipelineCfg( diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_common.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_common.py index f8f1e6106eca..2c8f8c6a19f5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_common.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_common.py @@ -163,17 +163,8 @@ class ShadowHandRobotCfg(PresetCfg): needs a per-engine value is a defect to fix in the asset, not a preset to add. """ - # ``spawn_path`` authors only the prototype env; the scene clone plan replicates the rest - newton_mjwarp = replace( - SHADOW_HAND_NEWTON_CFG, - prim_path="{ENV_REGEX_NS}/Robot", - spawn=replace(SHADOW_HAND_NEWTON_CFG.spawn, spawn_path="/World/envs/env_0/Robot"), - ) - isaacsim_physx = replace( - SHADOW_HAND_PHYSX_CFG, - prim_path="{ENV_REGEX_NS}/Robot", - spawn=replace(SHADOW_HAND_PHYSX_CFG.spawn, spawn_path="/World/envs/env_0/Robot"), - ) + newton_mjwarp = replace(SHADOW_HAND_NEWTON_CFG, prim_path="{ENV_REGEX_NS}/Robot") + isaacsim_physx = replace(SHADOW_HAND_PHYSX_CFG, prim_path="{ENV_REGEX_NS}/Robot") physx = isaacsim_physx ovphysx = isaacsim_physx default = newton_mjwarp @@ -182,7 +173,6 @@ class ShadowHandRobotCfg(PresetCfg): CUBE_CFG = RigidObjectCfg( prim_path="{ENV_REGEX_NS}/object", spawn=sim_utils.UsdFileCfg( - spawn_path="/World/envs/env_0/object", usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd", rigid_props=[ sim_utils.UsdPhysicsRigidBodyCfg(kinematic_enabled=False), diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_direct_camera_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_direct_camera_env_cfg.py index ac5a4bbc1e48..d42b8bdddb64 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_direct_camera_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/reorient/config/shadow_hand/shadow_hand_direct_camera_env_cfg.py @@ -77,7 +77,6 @@ class _ShadowHandBaseTiledCameraCfg(CameraCfg): ) data_types: list[str] = [] spawn: sim_utils.PinholeCameraCfg = sim_utils.PinholeCameraCfg( - spawn_path="/World/envs/env_0/Camera", focal_length=24.0, focus_distance=400.0, horizontal_aperture=20.955, diff --git a/source/isaaclab_tasks/test/contrib/test_shoelace.py b/source/isaaclab_tasks/test/contrib/test_shoelace.py index 9c4def33ca74..ccbcee875795 100644 --- a/source/isaaclab_tasks/test/contrib/test_shoelace.py +++ b/source/isaaclab_tasks/test/contrib/test_shoelace.py @@ -62,9 +62,11 @@ def test_cable_friction_uses_each_bound_usd_material() -> None: stage = Usd.Stage.CreateInMemory() builder = newton.ModelBuilder() builder.begin_world() + cfg = ShoelaceEnvCfg() + cfg.scene.clone_cfg.clone_template = "/Lab/Cells/Cell{}" expected = {} for side, friction in (("Left", 0.23), ("Right", 0.37)): - path = f"/World/envs/env_0/ShoelaceScene/Shoelace{side}/geometry/mesh" + path = f"/Lab/Cells/Cell0/ShoelaceScene/Shoelace{side}/geometry/mesh" curve = UsdGeom.BasisCurves.Define(stage, path).GetPrim() points = np.array([(0.0, 0.0, 0.0), (0.01, 0.0, 0.0), (0.02, 0.0, 0.0)]) segment_length = np.linalg.norm(np.diff(points, axis=0), axis=1).mean() @@ -78,7 +80,7 @@ def test_cable_friction_uses_each_bound_usd_material() -> None: expected.update({shape: friction for shape in builder.body_shapes[body]}) builder.end_world() - configure_shoelace_builder(builder, stage, 0, np.array([0.0, 0.0, 0.0, 1.0]), ShoelaceEnvCfg()) + configure_shoelace_builder(builder, stage, 0, np.array([0.0, 0.0, 0.0, 1.0]), cfg) model = builder.finalize(device="cpu") for shape, friction in expected.items(): assert model.shape_material_mu.numpy()[shape] == pytest.approx(friction) diff --git a/source/isaaclab_teleop/changelog.d/pr10-clone-paths.fixed.rst b/source/isaaclab_teleop/changelog.d/pr10-clone-paths.fixed.rst new file mode 100644 index 000000000000..01a5c12152cc --- /dev/null +++ b/source/isaaclab_teleop/changelog.d/pr10-clone-paths.fixed.rst @@ -0,0 +1,3 @@ +* Fixed XR camera-feed visibility and task-declared target and anchor paths for custom clone + namespaces. ``create_isaac_teleop_device`` now accepts ``env_prim_path`` to expand + ``{ENV_REGEX_NS}`` without changing the supplied configuration. diff --git a/source/isaaclab_teleop/isaaclab_teleop/camera_feed.py b/source/isaaclab_teleop/isaaclab_teleop/camera_feed.py index 450f79f74d07..624ce3f42b82 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/camera_feed.py +++ b/source/isaaclab_teleop/isaaclab_teleop/camera_feed.py @@ -500,6 +500,7 @@ def __init__( _panel_descriptor(cfg, self._layout_cfg), width=int(fallback_image.shape[1]), height=int(fallback_image.shape[0]), + env_prim_path=self._env.scene.env_prim_paths[0], ) except Exception: if image_source is not None: @@ -577,6 +578,7 @@ def _rebind_feed(self, feed: _ActiveFeed, camera: Camera, fallback_image: torch. _panel_descriptor(feed.cfg, self._layout_cfg), width=int(fallback_image.shape[1]), height=int(fallback_image.shape[0]), + env_prim_path=self._env.scene.env_prim_paths[0], ) except Exception: if camera_changed and replacement_source is not None: diff --git a/source/isaaclab_teleop/isaaclab_teleop/camera_feed_kit_scene_ui.py b/source/isaaclab_teleop/isaaclab_teleop/camera_feed_kit_scene_ui.py index d2af64666730..b70ebc1f286c 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/camera_feed_kit_scene_ui.py +++ b/source/isaaclab_teleop/isaaclab_teleop/camera_feed_kit_scene_ui.py @@ -359,6 +359,7 @@ def __init__( viewer_start_anchor: KitSceneUiViewerStartAnchor | None = None, head_locked_anchor: KitSceneUiHeadLockedAnchor | None = None, presenter: _KitSceneUiCameraFeedPresenter | None = None, + env_prim_path: str | None = None, ): """Create the image provider and attach its panel to the XR scene.""" self._closed = False @@ -370,6 +371,7 @@ def __init__( ) self._pose_registration = None self._presenter = presenter + self._env_prim_path = env_prim_path self._pose_ready = descriptor.placement == "world" self._partition_ready = presenter is None @@ -669,7 +671,9 @@ def prepare_upload_image( ) return torch.empty_like(image, device=previous_source.device, memory_format=torch.contiguous_format) - def create_panel(self, descriptor: Any, width: int, height: int) -> KitSceneUiCameraFeedPanel: + def create_panel( + self, descriptor: Any, width: int, height: int, *, env_prim_path: str | None = None + ) -> KitSceneUiCameraFeedPanel: viewer_start_anchor = None head_locked_anchor = None if descriptor.placement == "viewer_start": @@ -687,6 +691,7 @@ def create_panel(self, descriptor: Any, width: int, height: int) -> KitSceneUiCa viewer_start_anchor=viewer_start_anchor, head_locked_anchor=head_locked_anchor, presenter=self if descriptor.use_scene_partition else None, + env_prim_path=env_prim_path, ) @staticmethod @@ -757,13 +762,14 @@ def _refresh_scene_partition(cls) -> None: try: if get_settings_manager().get(ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING) is not False: raise RuntimeError("Isolated XR PiP requires showAllPartitionsByDefault=False.") - env_root = stage.GetPrimAtPath("/World/envs/env_0") - env_partition = env_root.GetAttribute("primvars:omni:scenePartition") if env_root else None - if env_partition and env_partition.Get() not in (None, ""): - raise RuntimeError( - "Isolated XR PiP requires an unpartitioned environment before camera creation; " - "late partition changes can hide instanced geometry." - ) + for env_path in {panel._env_prim_path for panel in cls._partition_panels} - {None}: + env_root = stage.GetPrimAtPath(env_path) + env_partition = env_root.GetAttribute("primvars:omni:scenePartition") if env_root else None + if env_partition and env_partition.Get() not in (None, ""): + raise RuntimeError( + "Isolated XR PiP requires an unpartitioned environment before camera creation; " + "late partition changes can hide instanced geometry." + ) camera = stage.GetPrimAtPath(_XR_CAMERA_PATH) if camera: cls._author_scene_partition(camera, "omni:scenePartition", _XR_CAMERA_PIP_PARTITION) diff --git a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py index dd5e4f651ba5..675a32e85761 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py +++ b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py @@ -294,11 +294,12 @@ def build_pipeline(): Typical usage: set to the robot base link prim path so that an IK controller receives end-effector poses in the robot's base frame. + :func:`create_isaac_teleop_device` expands ``{ENV_REGEX_NS}`` when given an ``env_prim_path``. Example:: IsaacTeleopCfg( - target_frame_prim_path="/World/envs/env_0/Robot/base_link", + target_frame_prim_path="{ENV_REGEX_NS}/Robot/base_link", ... ) """ diff --git a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py index 2170a29207db..3d3f05083205 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py +++ b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py @@ -16,6 +16,7 @@ from scipy.spatial.transform import Rotation from isaaclab.sim.utils.stage import get_current_stage_id +from isaaclab.utils import replace from .command_handler import CommandHandler from .control_events import ControlEvents @@ -511,6 +512,8 @@ def create_isaac_teleop_device( mcap_replay_path: str | None = None, enable_debug_visualization: bool = False, haptic_cfg: HapticFeedbackCfg | None = None, + *, + env_prim_path: str | None = None, ) -> IsaacTeleopDevice: """Create an :class:`IsaacTeleopDevice` with required Omniverse extension setup. @@ -525,8 +528,8 @@ def create_isaac_teleop_device( land on the same device the caller uses for the simulation. Note: - When *sim_device* is provided, ``cfg.sim_device`` is mutated in place - before the device is constructed. + Supplying *env_prim_path* copies the configuration before expanding paths. + Otherwise, *sim_device* overrides ``cfg.sim_device`` in place. Args: cfg: Isaac Capture configuration. @@ -565,6 +568,8 @@ def create_isaac_teleop_device( :class:`~isaaclab_teleop.HapticFeedbackReceiver` and renders per-hand output vectors on the configured device (controller, glove, ...). + env_prim_path: Concrete environment root used for ``{ENV_REGEX_NS}`` in target and XR anchor paths. + Returns: A fully configured :class:`IsaacTeleopDevice` ready for use in a ``with`` block. @@ -582,6 +587,13 @@ def create_isaac_teleop_device( if mcap_replay_path is None and use_kit_xr_bridge: _enable_teleop_bridge() + if env_prim_path is not None: + cfg = replace(cfg) + if cfg.target_frame_prim_path is not None: + cfg.target_frame_prim_path = cfg.target_frame_prim_path.replace("{ENV_REGEX_NS}", env_prim_path) + if cfg.xr_cfg.anchor_prim_path is not None: + cfg.xr_cfg.anchor_prim_path = cfg.xr_cfg.anchor_prim_path.replace("{ENV_REGEX_NS}", env_prim_path) + if sim_device is not None: cfg.sim_device = sim_device diff --git a/source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py b/source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py index 7382f8570252..7ec58e4c1ca0 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py +++ b/source/isaaclab_teleop/isaaclab_teleop/xr_cfg.py @@ -57,6 +57,8 @@ class XrCfg: When set, the XR anchor will be attached to the specified prim (e.g., robot root prim), allowing the XR camera to move with the prim. This is particularly useful for locomotion robot teleoperation where the robot moves and the XR camera should follow it. + :func:`~isaaclab_teleop.create_isaac_teleop_device` expands ``{ENV_REGEX_NS}`` when + given an ``env_prim_path``; direct device construction requires a concrete path. If None, the anchor will use the static :attr:`anchor_pos` and :attr:`anchor_rot` values. """ diff --git a/source/isaaclab_teleop/test/test_oxr_device.py b/source/isaaclab_teleop/test/test_oxr_device.py index 990f487556e0..752642c44f0b 100644 --- a/source/isaaclab_teleop/test/test_oxr_device.py +++ b/source/isaaclab_teleop/test/test_oxr_device.py @@ -24,7 +24,7 @@ from isaaclab_teleop import IsaacTeleopCfg from isaaclab_teleop.control_events import _NO_OP_EVENTS from isaaclab_teleop.deprecated.openxr import OpenXRDevice, OpenXRDeviceCfg, XrCfg -from isaaclab_teleop.isaac_teleop_device import IsaacTeleopDevice +from isaaclab_teleop.isaac_teleop_device import IsaacTeleopDevice, create_isaac_teleop_device from isaaclab_teleop.xr_anchor_utils import XrAnchorSynchronizer import carb @@ -202,14 +202,20 @@ def test_xr_anchor(empty_env, mock_xrcore, monkeypatch): session.last_control_events = _NO_OP_EVENTS monkeypatch.setattr("isaaclab_teleop.isaac_teleop_device.TeleopSessionLifecycle", lambda *args, **kwargs: session) monkeypatch.setattr("isaaclab_teleop.xr_anchor_manager.XRCore", None) - sim_utils.create_prim("/World/Reference") + sim_utils.create_prim("/Lab/Cells/Cell0/Reference") env.sim.reset() stage = usdrt.Usd.Stage.Attach(sim_utils.get_current_stage_id()) - reference = usdrt.Rt.Xformable(stage.GetPrimAtPath("/World/Reference")) + reference = usdrt.Rt.Xformable(stage.GetPrimAtPath("/Lab/Cells/Cell0/Reference")) world_matrix = reference.CreateFabricHierarchyWorldMatrixAttr() - cfg = IsaacTeleopCfg(target_frame_prim_path="/World/Reference", sim_device="cpu") - with IsaacTeleopDevice(cfg, use_kit_xr_bridge=False) as device: - xr_cfg = XrCfg(anchor_prim_path="/World/Reference", fixed_anchor_height=False) + cfg = IsaacTeleopCfg( + target_frame_prim_path="{ENV_REGEX_NS}/Reference", + sim_device="cpu", + xr_cfg=XrCfg(anchor_prim_path="{ENV_REGEX_NS}/Reference", fixed_anchor_height=False), + ) + with create_isaac_teleop_device(cfg, use_kit_xr_bridge=False, env_prim_path="/Lab/Cells/Cell0") as device: + assert cfg.target_frame_prim_path == "{ENV_REGEX_NS}/Reference" + assert cfg.xr_cfg.anchor_prim_path == "{ENV_REGEX_NS}/Reference" + xr_cfg = device._cfg.xr_cfg core = MagicMock() sync = XrAnchorSynchronizer(core, xr_cfg, "/World/XRAnchor") with monkeypatch.context() as bound: @@ -228,6 +234,7 @@ def test_xr_anchor(empty_env, mock_xrcore, monkeypatch): np.testing.assert_allclose(displayed.ExtractTranslation(), position) session.start.reset_mock() cfg.target_frame_prim_path = "/World/Missing" + cfg.xr_cfg = XrCfg() with pytest.raises(ValueError, match="No target frame"): with IsaacTeleopDevice(cfg, use_kit_xr_bridge=False): pass diff --git a/source/isaaclab_teleop/test/test_xr_camera_feed.py b/source/isaaclab_teleop/test/test_xr_camera_feed.py index 143b2edfefb3..67aa9e45bb26 100644 --- a/source/isaaclab_teleop/test/test_xr_camera_feed.py +++ b/source/isaaclab_teleop/test/test_xr_camera_feed.py @@ -320,7 +320,8 @@ def prepare_upload_image(self, name, image, previous_source=None, previous_uploa self.prepare_calls.append((name, image, previous_source, previous_upload)) return image - def create_panel(self, descriptor, width, height): + def create_panel(self, descriptor, width, height, *, env_prim_path): + assert env_prim_path == "/Lab/Cells/Cell0" panel = _FakePanel(descriptor, width, height) self.panels.append(panel) return panel @@ -367,7 +368,7 @@ def _manager(monkeypatch, cfgs, images, layout=None, source_images=None): cameras = {name: _FakeCamera(image) for name, image in images.items()} monkeypatch.setattr(camera_feed, "_camera_type", lambda: _FakeCamera) presenter = _FakePresenter(source_images) - env = SimpleNamespace(scene=SimpleNamespace(sensors=cameras)) + env = SimpleNamespace(scene=SimpleNamespace(sensors=cameras, env_prim_paths=["/Lab/Cells/Cell0"])) manager = camera_feed._XrCameraFeedManager(env, cfgs, layout or XrCameraFeedLayoutCfg(), presenter) return manager, presenter, cameras @@ -399,7 +400,7 @@ def test_nonisolated_pip_preserves_camera_and_global_renderer_settings(monkeypat settings = SimpleNamespace(get=lambda key: True, set=Mock(side_effect=AssertionError("Global settings changed"))) monkeypatch.setattr("isaaclab.app.settings_manager.get_settings_manager", lambda: settings) camera = _FakeCamera(_FakeImage()) - env = SimpleNamespace(scene=SimpleNamespace(sensors={"robot_pov_cam": camera})) + env = SimpleNamespace(scene=SimpleNamespace(sensors={"robot_pov_cam": camera}, env_prim_paths=["/Lab/Cells/Cell0"])) session = XrCameraFeedSession.prepare(env_cfg, enabled=True, camera_rendering_enabled=True) assert session.enabled assert env_cfg.scene.robot_pov_cam.renderer_cfg == expected @@ -614,7 +615,7 @@ def test_manager_uses_camera_buffer_when_image_source_is_unavailable(monkeypatch monkeypatch.setattr(camera_feed, "_camera_type", lambda: _FakeCamera) presenter = _FakePresenter() presenter.create_image_source = Mock(return_value=None) - env = SimpleNamespace(scene=SimpleNamespace(sensors={"robot_pov_cam": camera})) + env = SimpleNamespace(scene=SimpleNamespace(sensors={"robot_pov_cam": camera}, env_prim_paths=["/Lab/Cells/Cell0"])) manager = camera_feed._XrCameraFeedManager(env, [cfg], XrCameraFeedLayoutCfg(), presenter) presenter.subscription.publish() diff --git a/source/isaaclab_teleop/test/test_xr_camera_feed_kit_scene_ui.py b/source/isaaclab_teleop/test/test_xr_camera_feed_kit_scene_ui.py index 8bfe13109bb8..d0a04b916a00 100644 --- a/source/isaaclab_teleop/test/test_xr_camera_feed_kit_scene_ui.py +++ b/source/isaaclab_teleop/test/test_xr_camera_feed_kit_scene_ui.py @@ -316,7 +316,7 @@ def create(presenter=None, *, use_scene_partition=True): world_orientation_xyzw=(0.0, 0.0, 0.0, 1.0), use_scene_partition=use_scene_partition, ) - panel = presenter.create_panel(descriptor, 720, 480) + panel = presenter.create_panel(descriptor, 720, 480, env_prim_path="/Lab/Cells/Cell0") panels.append(panel) return panel @@ -362,7 +362,7 @@ def test_partition_rejects_late_environment_change_without_retaining_overrides( stage = Usd.Stage.CreateInMemory() monkeypatch.setattr(scene_ui_module, "get_current_stage", lambda: stage) stage.DefinePrim(scene_ui_module._XR_CAMERA_PATH, "Camera") - env = stage.DefinePrim("/World/envs/env_0", "Xform") + env = stage.DefinePrim("/Lab/Cells/Cell0", "Xform") env.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(partition) before = stage.GetSessionLayer().ExportToString() with pytest.raises(RuntimeError, match="unpartitioned environment before camera creation"): diff --git a/source/isaaclab_visualizers/changelog.d/pr10-clone-paths.fixed.rst b/source/isaaclab_visualizers/changelog.d/pr10-clone-paths.fixed.rst new file mode 100644 index 000000000000..7870aa595fbc --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/pr10-clone-paths.fixed.rst @@ -0,0 +1,2 @@ +* Fixed Kit viewport visibility, camera partition selection, and marker partitioning + for custom clone namespaces. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualization_markers.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualization_markers.py index db23dfd46bc8..121db6ed1aaa 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualization_markers.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualization_markers.py @@ -49,18 +49,19 @@ class KitVisualizationMarkers: _authored_environment_ids: tuple[int, ...] | None = None _scene_partitioning_active: bool = False - def __init__(self, cfg: VisualizationMarkersCfg, visible: bool = True): - """Initialize the USD point instancer and register marker prototypes. + def __init__( + self, cfg: VisualizationMarkersCfg, visible: bool = True, *, sim: sim_utils.SimulationContext | None = None + ): + """Create a USD point instancer and its prototypes at the next free prim path. - When this backend is initialized, the :class:`UsdGeom.PointInstancer` - is created in the stage and the marker prims are registered into it. - - .. note:: - If a prim already exists at the requested path, the next free path - is used for the :class:`UsdGeom.PointInstancer` prim. + Args: + cfg: Marker prototypes and requested prim path. + visible: Initial marker visibility. + sim: Owner of the scene and clone plan; omitted for standalone USD markers. """ self.cfg = cfg - self.stage = sim_utils.get_current_stage() + self._sim = sim + self.stage = sim.stage if sim is not None else sim_utils.get_current_stage() # Resolve the next free prim path before creating the point instancer. self.prim_path = sim_utils.get_next_free_prim_path(cfg.prim_path) self._is_visible = visible @@ -224,14 +225,17 @@ def _matches_cached_environment_ids(self, environment_ids: torch.Tensor) -> bool def _scene_partitioning_is_active(self) -> bool: """Return whether renderer stage preparation authored environment partitions. - Renderer preparation always starts with ``env_0``, so its root is the - canonical stage-level signal regardless of which environments own markers. + The first declared environment is the stage-level signal, regardless of + which environments own markers. """ # Markers can be created before the renderer prepares the stage, so a negative result is # re-checked. Partitions are never withdrawn from a stage, so a positive one is cached. if self._scene_partitioning_active: return True - env_prim = self.stage.GetPrimAtPath("/World/envs/env_0") + plan = self._sim.get_clone_plan() if self._sim is not None else None + if plan is None: + return False + env_prim = self.stage.GetPrimAtPath(plan.env_template.format(0)) if not env_prim.IsValid(): return False attr = env_prim.GetAttribute("primvars:omni:scenePartition") diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index db43c1896a97..fc6c7ba091c1 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -651,7 +651,7 @@ def _apply_viewport_camera_scene_partition(self, usd_stage: Usd.Stage, num_envs: RTX scene partitioning culls per-env geometry by the camera's non-primvar ``omni:scenePartition`` token. Interactive viewport cameras live outside - ``/World/envs`` and are created by Kit, so they do not inherit the env-root + the environment roots and are created by Kit, so they do not inherit the env-root primvar authored by the renderer. When the RTX spectator-view setting is enabled, leaving the viewport camera unpartitioned shows all environments. Otherwise, the viewport is assigned to the first visible environment. @@ -660,7 +660,7 @@ def _apply_viewport_camera_scene_partition(self, usd_stage: Usd.Stage, num_envs: if num_envs <= 0: return - env_prim = usd_stage.GetPrimAtPath("/World/envs/env_0") + env_prim = usd_stage.GetPrimAtPath(self._sim.get_clone_plan().env_template.format(0)) env_partition_attr = env_prim.GetAttribute("primvars:omni:scenePartition") if not env_partition_attr.IsValid() or env_partition_attr.Get() is None: return @@ -829,7 +829,7 @@ def _apply_env_visibility(self, usd_stage, num_envs: int, visible_env_ids: list[ for env_id in range(num_envs): if env_id in visible: continue - env_path = f"/World/envs/env_{env_id}" + env_path = self._sim.get_clone_plan().env_template.format(env_id) prim = usd_stage.GetPrimAtPath(env_path) if not prim.IsValid(): continue diff --git a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py index 86b4276bb7ca..67c142afe66d 100644 --- a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py +++ b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py @@ -8,7 +8,7 @@ import contextlib import signal import sys -from types import ModuleType +from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock import isaaclab_visualizers.kit.kit_visualizer as kit_visualizer_module @@ -263,14 +263,19 @@ def test_viewport_camera_partition_follows_global_view_setting( ) -> None: """Global view should leave the viewport unpartitioned; fallback view should select one environment.""" stage = Usd.Stage.CreateInMemory() - env_prim = stage.DefinePrim("/World/envs/env_0", "Xform") - env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set("env_0") + plan = SimpleNamespace(env_template="/Lab/Cell{}") + for index in range(4): + env_prim = UsdGeom.Xform.Define(stage, plan.env_template.format(index)).GetPrim() + env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(f"env_{index}") camera = UsdGeom.Camera.Define(stage, "/OmniverseKit_Persp") camera.GetPrim().CreateAttribute("omni:scenePartition", Sdf.ValueTypeNames.Token).Set("env_0") visualizer = object.__new__(KitVisualizer) + visualizer._sim = SimpleNamespace(stage=stage, get_clone_plan=lambda: plan) visualizer._controlled_camera_path = "/OmniverseKit_Persp" visualizer._env_ids = [2] + visualizer._hidden_env_visibilities = {} + visualizer._point_instancer_invisible_ids_backup = {} settings = MagicMock() settings.get.return_value = show_global_view monkeypatch.setattr(kit_visualizer_module, "get_settings_manager", lambda: settings) @@ -284,22 +289,16 @@ def test_viewport_camera_partition_follows_global_view_setting( else: assert partition_attr.Get() == expected_partition + UsdGeom.Imageable(stage.GetPrimAtPath(plan.env_template.format(0))).MakeInvisible() + visualizer._apply_env_visibility(stage, num_envs=4, visible_env_ids=[2]) + for index in range(4): + visibility = UsdGeom.Imageable(stage.GetPrimAtPath(plan.env_template.format(index))).GetVisibilityAttr().Get() + assert visibility == (UsdGeom.Tokens.inherited if index == 2 else UsdGeom.Tokens.invisible) -def test_marker_partition_detection_uses_canonical_environment_root() -> None: - """The renderer-authored env_0 root should be the stage-level partition signal.""" - stage = Usd.Stage.CreateInMemory() - env_1 = stage.DefinePrim("/World/envs/env_1", "Xform") - env_1.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set("env_1") - markers = object.__new__(KitVisualizationMarkers) - markers.stage = stage - markers._environment_ids = (1,) - - assert not markers._scene_partitioning_is_active() - - env_0 = stage.DefinePrim("/World/envs/env_0", "Xform") - env_0.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set("env_0") - - assert markers._scene_partitioning_is_active() + visualizer._restore_env_visibility() + for index in range(4): + visibility = UsdGeom.Imageable(stage.GetPrimAtPath(plan.env_template.format(index))).GetVisibilityAttr().Get() + assert visibility == (UsdGeom.Tokens.invisible if index == 0 else UsdGeom.Tokens.inherited) def test_marker_environment_ids_are_sticky_and_revalidated_on_change() -> None: @@ -310,6 +309,7 @@ def test_marker_environment_ids_are_sticky_and_revalidated_on_change() -> None: env_prim.CreateAttribute("primvars:omni:scenePartition", Sdf.ValueTypeNames.Token).Set(f"env_{env_id}") instancer = UsdGeom.PointInstancer.Define(stage, "/World/Visuals/markers") markers = object.__new__(KitVisualizationMarkers) + markers._sim = SimpleNamespace(get_clone_plan=lambda: SimpleNamespace(env_template="/World/envs/env_{}")) markers.stage = stage markers._instancer_manager = instancer markers._environment_ids = None