From 3b606548f85b2de720be9d529c3fff385222c6f5 Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Tue, 21 Apr 2026 14:24:55 -0700 Subject: [PATCH 01/37] Expand Visualizer Tests and Patch Visualizer Bugs (#5103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Tests - Add visualizer tests which load cartpole scene and check the viewport isn't black or frozen - Add regression tests for visualization pumping from Pascal's change - https://github.com/isaac-sim/IsaacLab/pull/5056 Newton Visualizer - Fix "Pause/Resume Rendering" button - Change "Pause/Resume Training" to "Pause/Resume Simulation" Kit Visualizer - Fix recording in headless mode - Resolve overlapping camera cfg behavior in ViewerCfg and KitVisualizerCfg (will likely need another PR + design to streamline the cfgs) Rerun Visualizer - Fix issue when launching rerun + newton physics without KitVisualizer RTX Renderer - Fix stale image issue after resets which require Kit Visualizer's continuous app updates to avoid - Fix strange wrist camera orientation offset issue Else - Rename camera_position/camera_target_position fields to eye/lookat across the board ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/features/visualization.rst | 57 +- source/isaaclab/isaaclab/app/app_launcher.py | 6 +- .../isaaclab/isaaclab/cli/commands/install.py | 23 +- source/isaaclab/isaaclab/envs/common.py | 52 +- .../isaaclab/isaaclab/envs/direct_marl_env.py | 8 +- .../isaaclab/isaaclab/envs/direct_rl_env.py | 8 +- .../isaaclab/envs/manager_based_env.py | 9 +- .../envs/ui/viewport_camera_controller.py | 13 +- .../isaaclab/envs/utils/recording_hooks.py | 50 ++ .../isaaclab/physics/physics_manager.py | 10 + .../isaaclab/sim/simulation_context.py | 39 +- .../isaaclab/visualizers/base_visualizer.py | 8 + .../isaaclab/visualizers/visualizer_cfg.py | 16 +- .../test/sim/test_simulation_context.py | 80 +++ .../test_simulation_context_visualizers.py | 18 + .../video_recording/__init__.py | 5 + .../video_recording/recording_hooks.py | 30 + .../renderers/isaac_rtx_renderer_utils.py | 43 +- .../renderers/kit_viewport_utils.py | 35 ++ .../kit/kit_visualizer.py | 115 ++-- .../kit/kit_visualizer_cfg.py | 17 +- .../newton/newton_visualizer.py | 66 +- .../rerun/rerun_visualizer.py | 23 +- .../viser/viser_visualizer.py | 25 +- .../test_visualizer_cartpole_integration.py | 595 ++++++++++++++++++ .../test/test_visualizer_smoke_logs.py | 228 ------- 26 files changed, 1163 insertions(+), 416 deletions(-) create mode 100644 source/isaaclab/isaaclab/envs/utils/recording_hooks.py create mode 100644 source/isaaclab_newton/isaaclab_newton/video_recording/recording_hooks.py create mode 100644 source/isaaclab_physx/isaaclab_physx/renderers/kit_viewport_utils.py create mode 100644 source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py delete mode 100644 source/isaaclab_visualizers/test/test_visualizer_smoke_logs.py diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 13d573308efb..c093fe18f00f 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -104,17 +104,14 @@ You can also configure custom visualizers in the code by defining ``VisualizerCf sim_cfg = SimulationCfg( visualizer_cfgs=[ KitVisualizerCfg( - viewport_name="Visualizer Viewport", - create_viewport=True, - dock_position="SAME", - window_width=1280, - window_height=720, - camera_position=(0.0, 0.0, 20.0), # high top down view - camera_target=(0.0, 0.0, 0.0), + # Omit create_viewport (default False) to use the active viewport; set + # create_viewport=True and optionally viewport_name to add a dedicated window. + eye=(0.0, 0.0, 20.0), # high top down view + lookat=(0.0, 0.0, 0.0), ), NewtonVisualizerCfg( - camera_position=(5.0, 5.0, 5.0), # closer quarter view - camera_target=(0.0, 0.0, 0.0), + eye=(5.0, 5.0, 5.0), # closer quarter view + lookat=(0.0, 0.0, 0.0), show_joints=True, ), RerunVisualizerCfg( @@ -193,20 +190,18 @@ Omniverse Visualizer from isaaclab_visualizers.kit import KitVisualizerCfg visualizer_cfg = KitVisualizerCfg( - # Viewport settings - viewport_name="Visualizer Viewport", # Viewport window name - create_viewport=True, # Create new viewport vs. use existing - dock_position="SAME", # Docking: 'LEFT', 'RIGHT', 'BOTTOM', 'SAME' - window_width=1280, # Viewport width in pixels - window_height=720, # Viewport height in pixels - - # Camera settings - camera_position=(8.0, 8.0, 3.0), # Initial camera position (x, y, z) - camera_target=(0.0, 0.0, 0.0), # Camera look-at target - - # Feature toggles - enable_markers=True, # Enable visualization markers - enable_live_plots=True, # Enable live plots (auto-expands frames) + # Viewport: default is create_viewport=False (use active viewport). + # Set create_viewport=True to create a docked window; viewport_name=None uses the default name. + create_viewport=False, + dock_position="SAME", + window_width=1280, + window_height=720, + + eye=(8.0, 8.0, 3.0), + lookat=(0.0, 0.0, 0.0), + + enable_markers=True, + enable_live_plots=True, ) @@ -217,7 +212,7 @@ Newton Visualizer - Lightweight OpenGL rendering with low overhead - Visualization markers (joints, contacts, springs, COM) -- Training and rendering pause controls +- Simulation and rendering pause controls - Adjustable update frequency for performance tuning - Some customizable rendering options (shadows, sky, wireframe) @@ -255,8 +250,8 @@ Newton Visualizer window_height=1080, # Window height in pixels # Camera settings - camera_position=(8.0, 8.0, 3.0), # Initial camera position (x, y, z) - camera_target=(0.0, 0.0, 0.0), # Camera look-at target + eye=(8.0, 8.0, 3.0), # Initial camera position (x, y, z) + lookat=(0.0, 0.0, 0.0), # Camera look-at target # Performance tuning update_frequency=1, # Update every N frames (1=every frame) @@ -303,8 +298,8 @@ Rerun Visualizer bind_address="0.0.0.0", # Endpoint host formatting/reuse checks # Camera settings - camera_position=(8.0, 8.0, 3.0), # Initial camera position (x, y, z) - camera_target=(0.0, 0.0, 0.0), # Camera look-at target + eye=(8.0, 8.0, 3.0), # Initial camera position (x, y, z) + lookat=(0.0, 0.0, 0.0), # Camera look-at target # History settings keep_historical_data=False, # Keep transforms for time scrubbing @@ -393,12 +388,6 @@ the num of environments can be overwritten and decreased using ``--num_envs``: python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Cartpole-v0 --viz rerun --num_envs 512 -.. note:: - - A future feature will support visualizing only a subset of environments, which will improve visualization performance - and reduce resource usage while maintaining full-scale training in the background. - - **Rerun Visualizer FPS Control** The FPS control in the Rerun visualizer UI may not affect the visualization frame rate in all configurations. diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index df6f7cb79e18..dcc8d1ca53e0 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -189,6 +189,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa self._offscreen_render: bool # 0: Disabled, 1: Enabled self._sim_experience_file: str # Experience file to load self._visualizer_max_worlds: int | None # Optional max worlds override for Newton-based visualizers + self._video_enabled: bool # Whether --video recording is enabled # Exposed to train scripts self.device_id: int # device ID for GPU simulation (defaults to 0) @@ -858,12 +859,13 @@ def _resolve_xr_settings(self, launcher_args: dict): def _resolve_viewport_settings(self, launcher_args: dict): """Resolve viewport related settings.""" + self._video_enabled = bool(launcher_args.get("video", False)) # Check if we can disable the viewport to improve performance # This should only happen if we are running headless and do not require livestreaming or video recording # This is different from offscreen_render because this only affects the default viewport and # not other render-products in the scene self._render_viewport = True - if self._headless and not self._livestream and not launcher_args.get("video", False): + if self._headless and not self._livestream and not self._video_enabled: self._render_viewport = False # hide_ui flag @@ -1085,6 +1087,8 @@ def _load_extensions(self): # (no Kit GUI) the AR profile must be enabled programmatically so that # the OpenXR session starts without user interaction settings.set_bool("/isaaclab/xr/auto_start", self._headless and self._xr) + # set setting to indicate video recording mode + settings.set_bool("/isaaclab/video/enabled", self._video_enabled) # set setting to indicate no RTX sensors are used (set to True when RTX sensor is created) settings.set_bool("/isaaclab/render/rtx_sensors", False) diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index c442cfd89fe6..1ee0c8cd0174 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -305,6 +305,9 @@ def _install_extra_frameworks(framework_name: str = "all") -> None: "newton_actuators", "warp", "mujoco_warp", + "websockets", + "viser", + "imgui_bundle", ] """Package directory names in Isaac Sim prebundle directories to repoint. @@ -352,7 +355,25 @@ def _repoint_prebundle_packages() -> None: print_warning(f"site-packages directory not found: {site_packages} — skipping prebundle repoint.") return - prebundle_dirs = list(isaacsim_path.rglob("pip_prebundle")) + # Discover pip_prebundle directories from both the Isaac Sim tree and + # Omniverse cache roots. Some Isaac Sim directories are symlinked into + # ~/.local/share/ov and may be missed by a plain rglob() on _isaac_sim. + candidate_roots: set[Path] = set() + for root in ( + isaacsim_path, + isaacsim_path.resolve(), + isaacsim_path / "extscache", + Path.home() / ".local" / "share" / "ov" / "data" / "exts", + Path.home() / ".local" / "share" / "ov" / "data" / "exts" / "v2", + ): + if root.exists(): + candidate_roots.add(root) + candidate_roots.add(root.resolve()) + + prebundle_dirs: set[Path] = set() + for root in candidate_roots: + prebundle_dirs.update(root.rglob("pip_prebundle")) + if not prebundle_dirs: print_debug("No pip_prebundle directories found under Isaac Sim.") return diff --git a/source/isaaclab/isaaclab/envs/common.py b/source/isaaclab/isaaclab/envs/common.py index f913005d1dbb..033b9c38610f 100644 --- a/source/isaaclab/isaaclab/envs/common.py +++ b/source/isaaclab/isaaclab/envs/common.py @@ -5,6 +5,8 @@ from __future__ import annotations +import warnings +from dataclasses import MISSING, fields from typing import Dict, Literal, TypeVar # noqa: UP035 import gymnasium as gym @@ -17,9 +19,28 @@ ## +def _viewer_cfg_value_matches_default(current: object, default: object) -> bool: + """Return True if ``current`` matches the dataclass field default (including list/tuple equivalence).""" + if current == default: + return True + if isinstance(current, (list, tuple)) and isinstance(default, (list, tuple)): + if len(current) != len(default): + return False + return all(a == b for a, b in zip(current, default, strict=True)) + return False + + @configclass class ViewerCfg: - """Configuration of the scene viewport camera.""" + """Configuration of the scene viewport camera. + + Note: + Overriding non-default fields is deprecated. In a future release, Isaac Sim viewport camera + configuration will be expressed only through ``KitVisualizerCfg`` under + ``SimulationCfg.visualizer_cfgs``; use ``NewtonVisualizerCfg`` for the Newton viewer. + Those visualizer configs replace the viewport camera pose, resolution, prim path, and + frame-origin behavior that this class used to configure. + """ eye: tuple[float, float, float] = (7.5, 7.5, 7.5) """Initial camera position (in m). Default is (7.5, 7.5, 7.5).""" @@ -67,6 +88,35 @@ class ViewerCfg: This quantity is only effective if :attr:`origin` is set to "asset_body". """ + def __post_init__(self) -> None: + # Dataclasses do not record which arguments were passed explicitly vs defaulted, and + # warning only on ``**kwargs`` would miss positional arguments. Comparing each field to + # its declared default catches any non-default effective configuration (including + # ``replace()`` and ``from_dict``), while keeping ``ViewerCfg()`` silent. + differing: list[str] = [] + for f in fields(self): + if not f.init: + continue + if f.default is not MISSING: + default_val = f.default + elif f.default_factory is not MISSING: + default_val = f.default_factory() + else: + continue + if not _viewer_cfg_value_matches_default(getattr(self, f.name), default_val): + differing.append(f.name) + if differing: + warnings.warn( + "ViewerCfg is deprecated when overriding default viewport camera fields " + f"({', '.join(sorted(differing))}). In a future release, Isaac Sim viewport camera " + "settings will be configured only through ``SimulationCfg.visualizer_cfgs`` using " + "``KitVisualizerCfg`` (viewport camera pose, resolution, prim path, and " + "frame-origin options). For the Newton viewer, use ``NewtonVisualizerCfg``. " + "Migrate overrides out of ``ViewerCfg`` accordingly.", + DeprecationWarning, + stacklevel=2, + ) + ## # Types. diff --git a/source/isaaclab/isaaclab/envs/direct_marl_env.py b/source/isaaclab/isaaclab/envs/direct_marl_env.py index c6009117f1b5..b325164ebe01 100644 --- a/source/isaaclab/isaaclab/envs/direct_marl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_marl_env.py @@ -151,10 +151,10 @@ def _init_sim(self, render_mode: str | None = None, **kwargs): # viewport is not available in other rendering modes so the function will throw a warning # FIXME: This needs to be fixed in the future when we unify the UI functionalities even for # non-rendering modes. - # Initialize when GUI is available OR when visualizers are active (headless rendering) - # Visualizers support camera updates via sim.set_camera_view() which forwards to all active visualizers - has_visualizers = bool(self.sim.get_setting("/isaaclab/visualizer")) - if self.sim.has_gui or has_visualizers: + # Initialize when a Kit viewport exists. ViewportCameraController uses omni.kit (renderer camera); + # skip in kitless Newton-only runs (e.g. --viz rerun) where no Kit app is running. + has_visualizers = self.sim.has_active_visualizers() + if (self.sim.has_gui or has_visualizers) and has_kit(): self.viewport_camera_controller = ViewportCameraController(self, self.cfg.viewer) else: self.viewport_camera_controller = None diff --git a/source/isaaclab/isaaclab/envs/direct_rl_env.py b/source/isaaclab/isaaclab/envs/direct_rl_env.py index 05dc8495dbcf..c67803ff8cf1 100644 --- a/source/isaaclab/isaaclab/envs/direct_rl_env.py +++ b/source/isaaclab/isaaclab/envs/direct_rl_env.py @@ -156,10 +156,10 @@ def _init_sim(self, render_mode: str | None = None, **kwargs): # viewport is not available in other rendering modes so the function will throw a warning # FIXME: This needs to be fixed in the future when we unify the UI functionalities even for # non-rendering modes. - # Initialize when GUI is available OR when visualizers are active (headless rendering) - # Visualizers support camera updates via sim.set_camera_view() which forwards to all active visualizers - has_visualizers = bool(self.sim.get_setting("/isaaclab/visualizer")) - if self.sim.has_gui or has_visualizers: + # Initialize when a Kit viewport exists. ViewportCameraController uses omni.kit (renderer camera); + # skip in kitless Newton-only runs (e.g. --viz rerun) where no Kit app is running. + has_visualizers = self.sim.has_active_visualizers() + if (self.sim.has_gui or has_visualizers) and has_kit(): self.viewport_camera_controller = ViewportCameraController(self, self.cfg.viewer) else: self.viewport_camera_controller = None diff --git a/source/isaaclab/isaaclab/envs/manager_based_env.py b/source/isaaclab/isaaclab/envs/manager_based_env.py index 1e8ca0576101..c63db4922c9e 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_env.py @@ -21,6 +21,7 @@ from isaaclab.utils.configclass import resolve_cfg_presets from isaaclab.utils.seed import configure_seed from isaaclab.utils.timer import Timer +from isaaclab.utils.version import has_kit from .common import VecEnvObs from .manager_based_env_cfg import ManagerBasedEnvCfg @@ -166,10 +167,10 @@ def _init_sim(self): # viewport is not available in other rendering modes so the function will throw a warning # FIXME: This needs to be fixed in the future when we unify the UI functionalities even for # non-rendering modes. - # Initialize when GUI is available OR when visualizers are active (headless rendering) - # Visualizers support camera updates via sim.set_camera_view() which forwards to all active visualizers - has_visualizers = bool(self.sim.get_setting("/isaaclab/visualizer")) - if self.sim.has_gui or has_visualizers: + # Initialize when a Kit viewport exists. ViewportCameraController uses omni.kit (renderer camera); + # skip in kitless Newton-only runs (e.g. --viz rerun) where no Kit app is running. + has_visualizers = self.sim.has_active_visualizers() + if (self.sim.has_gui or has_visualizers) and has_kit(): self.viewport_camera_controller = ViewportCameraController(self, self.cfg.viewer) else: self.viewport_camera_controller = None diff --git a/source/isaaclab/isaaclab/envs/ui/viewport_camera_controller.py b/source/isaaclab/isaaclab/envs/ui/viewport_camera_controller.py index 4126d7b74735..277982e7a2c9 100644 --- a/source/isaaclab/isaaclab/envs/ui/viewport_camera_controller.py +++ b/source/isaaclab/isaaclab/envs/ui/viewport_camera_controller.py @@ -218,8 +218,17 @@ def update_view_location(self, eye: Sequence[float] | None = None, lookat: Seque cam_eye = viewer_origin + self.default_cam_eye cam_target = viewer_origin + self.default_cam_lookat - # set the camera view - self._env.sim.set_camera_view(eye=cam_eye, target=cam_target) + eye_t = (float(cam_eye[0]), float(cam_eye[1]), float(cam_eye[2])) + target_t = (float(cam_target[0]), float(cam_target[1]), float(cam_target[2])) + self._env.sim.set_camera_view(eye=eye_t, target=target_t) + + # Renderer viewport camera (Isaac RTX / Kit); optional — pure-Newton installs have no isaaclab_physx. + try: + from isaaclab_physx.renderers.kit_viewport_utils import set_kit_renderer_camera_view + + set_kit_renderer_camera_view(eye=cam_eye, target=cam_target, camera_prim_path=self.cfg.cam_prim_path) + except (ImportError, ModuleNotFoundError): + pass """ Private Functions diff --git a/source/isaaclab/isaaclab/envs/utils/recording_hooks.py b/source/isaaclab/isaaclab/envs/utils/recording_hooks.py new file mode 100644 index 000000000000..584b6d73adf5 --- /dev/null +++ b/source/isaaclab/isaaclab/envs/utils/recording_hooks.py @@ -0,0 +1,50 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Hooks that run after visualizers during :meth:`~isaaclab.sim.SimulationContext.render`. + +Lives alongside :mod:`video_recorder` / :mod:`video_recorder_cfg` because both tie into +``--video`` / ``rgb_array`` recording. Keeps :class:`~isaaclab.sim.SimulationContext` free +of imports from ``isaaclab_physx``, ``isaaclab_newton``, and other recording backends. +Each integration is loaded lazily so optional extensions are not required at import time. +""" + +from __future__ import annotations + +from typing import Any + + +def run_recording_hooks_after_visualizers(sim: Any) -> None: + """Run recording-related work after :meth:`~isaaclab.sim.SimulationContext.render` steps visualizers. + + Isaac Sim / RTX follow-up is loaded lazily so minimal installs still work. + Newton GL video is handled by :class:`~isaaclab.envs.utils.video_recorder.VideoRecorder` + (e.g. :class:`~isaaclab_newton.video_recording.newton_gl_perspective_video.NewtonGlPerspectiveVideo`), + not here. + + Args: + sim: Active :class:`~isaaclab.sim.SimulationContext` instance. + """ + _recording_followup_isaac_sim(sim) + + +def _recording_followup_isaac_sim(sim: Any) -> None: + """Isaac Sim: keep RTX / Replicator outputs fresh when recording video without a Kit visualizer. + + When ``--video`` uses ``rgb_array`` / :class:`~gymnasium.wrappers.RecordVideo`, Replicator + render products must see Kit's event loop pumped. :class:`~isaaclab_visualizers.kit.KitVisualizer` + already calls ``omni.kit.app.get_app().update()`` in its ``step()``; if no such visualizer + is active, we pump here (guarded by ``/isaaclab/video/enabled`` and ``is_rendering``). + + Implemented by ``pump_kit_app_for_headless_video_render_if_needed`` in + :mod:`isaaclab_physx.renderers.isaac_rtx_renderer_utils`. + """ + try: + from isaaclab_physx.renderers.isaac_rtx_renderer_utils import ( + pump_kit_app_for_headless_video_render_if_needed, + ) + except ImportError: + return + pump_kit_app_for_headless_video_render_if_needed(sim) diff --git a/source/isaaclab/isaaclab/physics/physics_manager.py b/source/isaaclab/isaaclab/physics/physics_manager.py index cc18582bc80e..7a4cdfe84403 100644 --- a/source/isaaclab/isaaclab/physics/physics_manager.py +++ b/source/isaaclab/isaaclab/physics/physics_manager.py @@ -276,6 +276,16 @@ def pre_render(cls) -> None: """ pass + @classmethod + def after_visualizers_render(cls) -> None: + """Hook after visualizers have stepped during :meth:`~isaaclab.sim.SimulationContext.render`. + + Use for physics-backend sync (e.g. fabric) if needed. Recording pipelines (Kit/RTX, + Newton GL video, etc.) run from :mod:`isaaclab.envs.utils.recording_hooks` so they are not + tied to a specific physics manager. Default is a no-op. + """ + pass + @classmethod def close(cls) -> None: """Clean up physics resources. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 5e05dab92a46..fa5427bcb24f 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -21,6 +21,7 @@ import isaaclab.sim as sim_utils import isaaclab.sim.utils.stage as stage_utils from isaaclab.app.settings_manager import SettingsManager +from isaaclab.envs.utils.recording_hooks import run_recording_hooks_after_visualizers from isaaclab.physics import BaseSceneDataProvider, PhysicsManager, SceneDataProvider from isaaclab.physics.scene_data_requirements import ( SceneDataRequirement, @@ -181,6 +182,7 @@ def __init__(self, cfg: SimulationCfg | None = None): self._has_offscreen_render = bool(self.get_setting("/isaaclab/render/offscreen")) self._xr_enabled = bool(self.get_setting("/isaaclab/xr/enabled")) # Note: has_rtx_sensors is NOT cached because it changes when Camera sensors are created + self._pending_camera_view: tuple[tuple[float, float, float], tuple[float, float, float]] | None = None # Simulation state self._is_playing = False @@ -188,6 +190,10 @@ def __init__(self, cfg: SimulationCfg | None = None): # Monotonic physics-step counter used by camera sensors for self._physics_step_count: int = 0 + # Monotonic render-generation counter. This increments whenever render() + # is executed and lets downstream camera freshness logic distinguish + # render/reset transitions that occur without advancing physics steps. + self._render_generation: int = 0 type(self)._instance = self # Mark as valid singleton only after successful init @@ -290,7 +296,8 @@ def _init_usd_physics_scene(self) -> None: UsdPhysics.SetStageKilogramsPerUnit(self.stage, 1.0) # Find and delete any existing physics scene. - # Collect paths first to avoid iterator invalidation during deletion. + # Collect paths first to avoid mutating the stage while traversing, + # which can invalidate the USD iterator. physics_scene_paths = [ prim.GetPath().pathString for prim in self.stage.Traverse() if prim.GetTypeName() == "PhysicsScene" ] @@ -340,6 +347,10 @@ def has_offscreen_render(self) -> bool: """Returns whether offscreen rendering is enabled (cached at init).""" return self._has_offscreen_render + def has_active_visualizers(self) -> bool: + """Return whether any visualizer path is active for rendering/camera control.""" + return bool(self.get_setting("/isaaclab/visualizer/types")) + @property def is_rendering(self) -> bool: """Returns whether rendering is active (GUI, RTX sensors, visualizers, or XR).""" @@ -355,6 +366,11 @@ def get_physics_dt(self) -> float: """Returns the physics time step.""" return self.physics_manager.get_physics_dt() + @property + def render_generation(self) -> int: + """Returns a monotonic counter for render() executions.""" + return self._render_generation + def _create_default_visualizer_configs(self, requested_visualizers: list[str]) -> list: """Create default visualizer configs for requested types. @@ -580,6 +596,14 @@ def initialize_visualizers(self) -> None: exc, ) + # Replay any camera pose requested before visualizers were initialized. + pending = getattr(self, "_pending_camera_view", None) + if pending is not None: + eye, target = pending + for viz in self._visualizers: + viz.set_camera_view(eye, target) + self._pending_camera_view = None + if not self._visualizers and self._scene_data_provider is not None: close_provider = getattr(self._scene_data_provider, "close", None) if callable(close_provider): @@ -630,6 +654,7 @@ def get_rendering_dt(self) -> float: def set_camera_view(self, eye: tuple, target: tuple) -> None: """Set camera view on all visualizers that support it.""" + self._pending_camera_view = (tuple(eye), tuple(target)) for viz in self._visualizers: viz.set_camera_view(eye, target) @@ -676,10 +701,15 @@ def render(self, mode: int | None = None) -> None: Calls update_visualizers() so visualizers run at the render cadence (not at every physics step). Camera sensors drive their configured renderer when - fetching data, so this method remains backend-agnostic. + fetching data. Recording-related follow-up (Kit/RTX headless video, Newton GL + video, etc.) runs in :mod:`isaaclab.envs.utils.recording_hooks` so it is not tied to a + specific :class:`~isaaclab.physics.PhysicsManager` subclass. """ self.physics_manager.pre_render() self.update_visualizers(self.get_rendering_dt()) + self.physics_manager.after_visualizers_render() + run_recording_hooks_after_visualizers(self) + self._render_generation += 1 # Call render callbacks if hasattr(self, "_render_callbacks"): @@ -704,6 +734,11 @@ def update_visualizers(self, dt: float) -> None: visualizers_to_remove.append(viz) continue if viz.is_rendering_paused(): + # Keep non-Kit visualizer event loops responsive while rendering is paused. + # Newton/Rerun/Viser need step(0.0) so GL/UI can process input (e.g. Resume). + # Kit is skipped: step() would call app.update(), which must not run during pause. + if not viz.pumps_app_update(): + viz.step(0.0) continue while viz.is_training_paused() and viz.is_running(): viz.step(0.0) diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index 2480bc89bbaa..e8a896ad5628 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -187,6 +187,14 @@ def set_camera_view(self, eye: tuple, target: tuple) -> None: """ pass + def _resolve_cfg_camera_pose( + self, _visualizer_name: str + ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: + """Resolve camera pose from cfg eye/lookat fields.""" + eye = tuple(float(v) for v in self.cfg.eye) + lookat = tuple(float(v) for v in self.cfg.lookat) + return eye, lookat + def _resolve_camera_pose_from_usd_path( self, usd_path: str ) -> tuple[tuple[float, float, float], tuple[float, float, float]] | None: diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index a96e3c04d2b5..3f62c3e5232e 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -34,17 +34,17 @@ class VisualizerCfg: enable_live_plots: bool = True """Enable live plotting of data.""" - camera_position: tuple[float, float, float] = (8.0, 8.0, 3.0) - """Initial camera position (x, y, z) in world coordinates.""" + eye: tuple[float, float, float] = (7.5, 7.5, 7.5) + """Initial camera eye position (x, y, z) in world coordinates.""" - camera_target: tuple[float, float, float] = (0.0, 0.0, 0.0) - """Initial camera target/look-at point (x, y, z) in world coordinates.""" + lookat: tuple[float, float, float] = (0.0, 0.0, 0.0) + """Initial camera look-at point (x, y, z) in world coordinates.""" - camera_source: Literal["cfg", "usd_path"] = "cfg" - """Camera source mode: 'cfg' uses camera_position/target, 'usd_path' follows a USD camera prim.""" + cam_source: Literal["cfg", "prim_path"] = "cfg" + """Camera source mode: 'cfg' uses eye/lookat, 'prim_path' follows a camera prim.""" - camera_usd_path: str = "/World/envs/env_0/Camera" - """Absolute USD path to a camera prim when camera_source='usd_path'.""" + cam_prim_path: str = "/World/envs/env_0/Camera" + """Absolute USD path to a camera prim when cam_source='prim_path'.""" env_filter_mode: Literal["none", "env_ids", "random_n"] = "none" """Env filter mode: 'none', 'env_ids', or 'random_n'.""" diff --git a/source/isaaclab/test/sim/test_simulation_context.py b/source/isaaclab/test/sim/test_simulation_context.py index c03413838e3e..6ea578a85e30 100644 --- a/source/isaaclab/test/sim/test_simulation_context.py +++ b/source/isaaclab/test/sim/test_simulation_context.py @@ -290,6 +290,86 @@ def test_render(): assert sim.is_playing() +@pytest.mark.isaacsim_ci +def test_render_pumps_app_update_without_visualizer(): + """Regression test for issue #5052: headless video must pump Kit when no visualizer does. + + Originally ``SimulationContext.render()`` called ``omni.kit.app.get_app().update()`` when + no visualizer had ``pumps_app_update()`` (see PR #5056). The same contract is now implemented + from :func:`~isaaclab.envs.utils.recording_hooks.run_recording_hooks_after_visualizers`, which calls + :func:`~isaaclab_physx.renderers.isaac_rtx_renderer_utils.pump_kit_app_for_headless_video_render_if_needed` + when ``/isaaclab/video/enabled`` is set (as with ``--video``), which in turn calls + ``ensure_isaac_rtx_render_update()`` (guarded by ``is_rendering`` and the no-pumping-visualizer check). + + Without this path, replicator render products used for ``rgb_array`` / RecordVideo stay stale (black frames). + """ + from unittest.mock import MagicMock, patch + + cfg = SimulationCfg(dt=0.01) + sim = SimulationContext(cfg) + sim.reset() + + sim.set_setting("/isaaclab/video/enabled", True) + sim.set_setting("/isaaclab/render/rtx_sensors", True) + + mock_app = MagicMock() + mock_app.is_running.return_value = True + + with ( + patch("isaaclab.utils.version.has_kit", return_value=True), + patch( + "isaaclab_physx.renderers.isaac_rtx_renderer_utils._get_stage_streaming_busy", + return_value=False, + ), + patch("omni.kit.app.get_app", return_value=mock_app), + ): + sim.render() + + mock_app.update.assert_called_once() + + +@pytest.mark.isaacsim_ci +def test_render_skips_app_update_when_visualizer_pumps_it(): + """Regression test: do not pump Kit in the headless-video path when a visualizer already does. + + A visualizer with ``pumps_app_update() == True`` (e.g. KitVisualizer) calls ``app.update()`` in + its own ``step()``. The recording-hook pump must then skip + ``ensure_isaac_rtx_render_update`` so we do not double-pump the Kit loop. + """ + from unittest.mock import MagicMock, patch + + from isaaclab.visualizers.base_visualizer import BaseVisualizer + + cfg = SimulationCfg(dt=0.01) + sim = SimulationContext(cfg) + sim.reset() + + sim.set_setting("/isaaclab/video/enabled", True) + sim.set_setting("/isaaclab/render/rtx_sensors", True) + + mock_viz = MagicMock(spec=BaseVisualizer) + mock_viz.pumps_app_update.return_value = True + mock_viz.is_closed = False + mock_viz.is_running.return_value = True + mock_viz.is_rendering_paused.return_value = False + mock_viz.is_training_paused.return_value = False + mock_viz.get_rendering_dt.return_value = None + sim._visualizers = [mock_viz] + + mock_app = MagicMock() + mock_app.is_running.return_value = True + + with ( + patch("isaaclab.utils.version.has_kit", return_value=True), + patch("omni.kit.app.get_app", return_value=mock_app), + ): + sim.render() + + mock_app.update.assert_not_called() + + sim._visualizers = [] + + """ Stage Operations Tests """ diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index f0a1294d2b4a..07d9ab0cb4c4 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -45,6 +45,7 @@ def __init__( training_paused_steps=0, raises_on_step=False, requires_forward=False, + pumps_app_update=False, ): self._env_ids = env_ids self._running = running @@ -53,6 +54,7 @@ def __init__( self._training_paused_steps = training_paused_steps self._raises_on_step = raises_on_step self._requires_forward = requires_forward + self._pumps_app_update = pumps_app_update self.step_calls = [] self.close_calls = 0 @@ -87,6 +89,9 @@ def get_visualized_env_ids(self): def requires_forward_before_step(self): return self._requires_forward + def pumps_app_update(self): + return self._pumps_app_update + def _make_context(visualizers, provider=None): ctx = object.__new__(SimulationContext) @@ -136,10 +141,21 @@ def test_update_visualizers_removes_closed_nonrunning_and_failed(caplog): assert stopped_viz.close_calls == 1 assert failing_viz.close_calls == 1 assert paused_viz.close_calls == 0 + assert paused_viz.step_calls == [0.0] assert healthy_viz.step_calls == [0.1] assert any("Error stepping visualizer" in r.message for r in caplog.records) +def test_update_visualizers_skips_zero_dt_for_paused_app_pumping_visualizer(): + provider = _FakeProvider() + paused_app_pumping_viz = _FakeVisualizer(rendering_paused=True, pumps_app_update=True) + ctx = _make_context([paused_app_pumping_viz], provider=provider) + + ctx.update_visualizers(0.3) + + assert paused_app_pumping_viz.step_calls == [] + + def test_update_visualizers_handles_training_pause_loop(): provider = _FakeProvider() viz = _FakeVisualizer(training_paused_steps=1) @@ -398,6 +414,8 @@ def _make_context_with_settings( ctx._has_gui = has_gui ctx._has_offscreen_render = has_offscreen_render ctx._xr_enabled = False + ctx._pending_camera_view = None + ctx._render_generation = 0 ctx._visualizers = [] ctx._scene_data_provider = _FakeProvider() ctx._scene_data_requirements = None diff --git a/source/isaaclab_newton/isaaclab_newton/video_recording/__init__.py b/source/isaaclab_newton/isaaclab_newton/video_recording/__init__.py index 3248ca5f13b4..1d5cb96e0ef3 100644 --- a/source/isaaclab_newton/isaaclab_newton/video_recording/__init__.py +++ b/source/isaaclab_newton/isaaclab_newton/video_recording/__init__.py @@ -4,3 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause """Newton GL perspective video recording.""" + +from .newton_gl_perspective_video import NewtonGlPerspectiveVideo +from .newton_gl_perspective_video_cfg import NewtonGlPerspectiveVideoCfg + +__all__ = ["NewtonGlPerspectiveVideo", "NewtonGlPerspectiveVideoCfg"] diff --git a/source/isaaclab_newton/isaaclab_newton/video_recording/recording_hooks.py b/source/isaaclab_newton/isaaclab_newton/video_recording/recording_hooks.py new file mode 100644 index 000000000000..7efcae7b5500 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/video_recording/recording_hooks.py @@ -0,0 +1,30 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Hooks for Newton-based video recording after visualizers have stepped.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from isaaclab.sim import SimulationContext + + +def recording_followup_after_visualizers(sim: SimulationContext) -> None: + """Newton extension hook: recording pipeline after visualizers have stepped. + + Called from :func:`isaaclab.envs.utils.recording_hooks.run_recording_hooks_after_visualizers`. + Wire **Newton GL** / Newton-specific video capture here (e.g. perspective video, + frame sync with ``NewtonVisualizer``). Stay lightweight and no-op when Newton + recording is inactive. + + The Isaac Sim / RTX path (``omni.kit.app`` pump for Replicator ``rgb_array``) lives in + :mod:`isaaclab_physx.renderers.isaac_rtx_renderer_utils` — not here. + + Args: + sim: Active simulation context. + """ + _ = sim # Reserved until Newton GL video paths are hooked up. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index 032bf001c79f..719bf70890b0 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -9,16 +9,17 @@ import logging import time +from typing import Any import isaaclab.sim as sim_utils logger = logging.getLogger(__name__) -# Module-level dedup stamp: tracks the last (sim instance, physics step) at +# Module-level dedup stamp: tracks the last (sim instance, physics step, render generation) at # which Kit's ``app.update()`` was pumped. Keyed on ``id(sim)`` so that a # new ``SimulationContext`` (e.g. in a new test) automatically invalidates # any stale stamp from a previous instance. -_last_render_update_key: tuple[int, int] = (0, -1) +_last_render_update_key: tuple[int, int, int] = (0, -1, -1) _STREAMING_WAIT_TIMEOUT_S: float = 30.0 @@ -58,7 +59,7 @@ def _wait_for_streaming_complete() -> None: def ensure_isaac_rtx_render_update() -> None: - """Ensure the Isaac RTX renderer has been pumped for the current physics step. + """Ensure the Isaac RTX renderer has been pumped for the current sim step. This keeps the Kit-specific ``app.update()`` logic inside the renderers package rather than in the backend-agnostic ``SimulationContext``. @@ -66,11 +67,11 @@ def ensure_isaac_rtx_render_update() -> None: Safe to call from multiple ``Camera`` / ``TiledCamera`` instances per step — only the first call triggers ``app.update()``. Subsequent calls are no-ops because the module-level ``_last_render_update_key`` already matches the - current ``(id(sim), step_count)`` pair. + current ``(id(sim), step_count, render_generation)`` tuple. - The key is a ``(sim_instance_id, step_count)`` tuple so that creating a new - ``SimulationContext`` (e.g. in a subsequent test) automatically invalidates - any stale stamp left over from a previous instance. + The key is a ``(sim_instance_id, step_count, render_generation)`` tuple so that: + - creating a new ``SimulationContext`` invalidates stale stamps, and + - render/reset transitions that do not advance physics step count still force a fresh update. After the initial ``app.update()`` the streaming subsystem is queried synchronously via ``UsdContext.get_stage_streaming_status()``. If textures @@ -88,7 +89,8 @@ def ensure_isaac_rtx_render_update() -> None: if sim is None: return - key = (id(sim), sim._physics_step_count) + render_generation = getattr(sim, "render_generation", getattr(sim, "_render_generation", 0)) + key = (id(sim), sim._physics_step_count, render_generation) if _last_render_update_key == key: return # Already pumped this step (by another camera or a visualizer) @@ -116,3 +118,28 @@ def ensure_isaac_rtx_render_update() -> None: sim.set_setting("/app/player/playSimulations", True) _last_render_update_key = key + + +def pump_kit_app_for_headless_video_render_if_needed(sim: Any) -> None: + """Pump Kit app-loop for headless rgb-array rendering when needed. + + Isaac Sim / RTX specific; kept out of backend-agnostic :class:`~isaaclab.sim.SimulationContext`. + """ + if not bool(sim.get_setting("/isaaclab/video/enabled")): + return + + from isaaclab.utils.version import has_kit + + if not has_kit(): + return + if any(viz.pumps_app_update() for viz in sim.visualizers): + return + try: + ensure_isaac_rtx_render_update() + except (ImportError, AttributeError, ModuleNotFoundError) as exc: + logger.debug("[isaac_rtx] Skipping Kit app-loop pump in render() (non-Kit env): %s", exc) + except Exception as exc: + logger.warning( + "[isaac_rtx] Kit app-loop pump failed in render() — video frames may be stale or black: %s", + exc, + ) diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/kit_viewport_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/kit_viewport_utils.py new file mode 100644 index 000000000000..af421a032399 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/renderers/kit_viewport_utils.py @@ -0,0 +1,35 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Kit / Omniverse viewport helpers (Isaac Sim specific). + +These live in :mod:`isaaclab_physx` so :class:`~isaaclab.sim.SimulationContext` stays +backend-agnostic. +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def set_kit_renderer_camera_view( + eye: tuple[float, float, float] | list[float], + target: tuple[float, float, float] | list[float], + camera_prim_path: str = "/OmniverseKit_Persp", +) -> None: + """Set camera view for the renderer/viewport camera only. + + This does not broadcast to visualizers. + """ + try: + import isaacsim.core.utils.viewports as isaacsim_viewports + + isaacsim_viewports.set_camera_view(eye=list(eye), target=list(target), camera_prim_path=str(camera_prim_path)) + except (ImportError, ModuleNotFoundError) as exc: + logger.debug("[kit_viewport] Renderer camera update skipped (no Kit): %s", exc) + except Exception as exc: + logger.warning("[kit_viewport] Renderer camera update failed: %s", exc) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 6b1b5c2077dc..3ad3ffd01326 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -13,6 +13,7 @@ from pxr import UsdGeom +from isaaclab.app.settings_manager import get_settings_manager from isaaclab.visualizers.base_visualizer import BaseVisualizer from .kit_visualizer_cfg import KitVisualizerCfg @@ -22,6 +23,8 @@ if TYPE_CHECKING: from isaaclab.physics import BaseSceneDataProvider +_DEFAULT_VIEWPORT_NAME = "Visualizer Viewport" + class KitVisualizer(BaseVisualizer): """Kit visualizer using Isaac Sim viewport.""" @@ -42,10 +45,9 @@ def __init__(self, cfg: KitVisualizerCfg): self._sim_time = 0.0 self._step_counter = 0 self._hidden_env_visibilities: dict[str, str] = {} - # Camera prim path that set_camera_view() writes to. Pinned at initialization so that - # user-switching the GUI viewport to a sensor camera does not corrupt the sensor's prim. - self._controlled_camera_path: str | None = None self._runtime_headless = bool(cfg.headless) + # USD path for the viewport's active camera, refreshed after setup (used by CI/tests). + self._controlled_camera_path: str | None = None # ---- Lifecycle ------------------------------------------------------------------------ @@ -68,7 +70,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: metadata = scene_data_provider.get_metadata() self._ensure_simulation_app() - self._setup_viewport(usd_stage) + self._setup_viewport() self._env_ids = self._compute_visualized_env_ids() if self._env_ids: @@ -81,9 +83,9 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: logger=logger, title="KitVisualizer Configuration", rows=[ - ("camera_position", self.cfg.camera_position), - ("camera_target", self.cfg.camera_target), - ("camera_source", self.cfg.camera_source), + ("eye", self.cfg.eye), + ("lookat", self.cfg.lookat), + ("cam_source", self.cfg.cam_source), ("num_visualized_envs", num_visualized_envs), ("create_viewport", self.cfg.create_viewport), ("headless", self._runtime_headless), @@ -105,12 +107,10 @@ def step(self, dt: float) -> None: try: import omni.kit.app - from isaaclab.app.settings_manager import get_settings_manager - app = omni.kit.app.get_app() if app is not None and app.is_running(): - # Keep app pumping for viewport/UI updates only. - # Simulation stepping is owned by SimulationContext. + # Keep app pumping for viewport/UI updates only; physics is owned by SimulationContext. + # Disable playSimulations around app.update() so Kit does not advance its own physics here. settings = get_settings_manager() settings.set_bool("/app/player/playSimulations", False) app.update() @@ -150,8 +150,6 @@ def is_running(self) -> bool: def is_training_paused(self) -> bool: """Return whether simulation play flag is paused in Kit settings.""" try: - from isaaclab.app.settings_manager import get_settings_manager - settings = get_settings_manager() play_flag = settings.get("/app/player/playSimulations") return play_flag is False @@ -215,22 +213,33 @@ def _ensure_simulation_app(self) -> None: except ImportError: pass - def _setup_viewport(self, usd_stage) -> None: - """Create/resolve viewport and configure initial camera. - - Args: - usd_stage: USD stage used for camera prim setup. - """ + def _setup_viewport(self) -> None: + """Create/resolve viewport and configure initial camera.""" import omni.kit.viewport.utility as vp_utils from omni.ui import DockPosition if self._runtime_headless: - # In headless mode we keep the visualizer active but skip viewport/window setup. + # Headless: no viewport window; apply cfg pose to the default perspective camera path. self._viewport_window = None self._viewport_api = None + if self.cfg.cam_source == "prim_path": + logger.warning( + "[KitVisualizer] cam_source='prim_path' has limited support in headless mode; " + "using eye/lookat from cfg instead." + ) + self._apply_cfg_camera_pose_if_configured() + self._refresh_controlled_camera_path() return - if self.cfg.create_viewport and self.cfg.viewport_name: + effective_viewport_name = ( + self.cfg.viewport_name if self.cfg.viewport_name is not None else _DEFAULT_VIEWPORT_NAME + ) + + if self.cfg.create_viewport: + if not str(effective_viewport_name).strip(): + raise RuntimeError( + "[KitVisualizer] viewport_name must be a non-empty string when create_viewport=True." + ) dock_position_name = self.cfg.dock_position.upper() dock_position_map = { "LEFT": DockPosition.LEFT, @@ -241,7 +250,7 @@ def _setup_viewport(self, usd_stage) -> None: dock_pos = dock_position_map.get(dock_position_name, DockPosition.SAME) self._viewport_window = vp_utils.create_viewport_window( - name=self.cfg.viewport_name, + name=effective_viewport_name, width=self.cfg.window_width, height=self.cfg.window_height, position_x=50, @@ -249,28 +258,33 @@ def _setup_viewport(self, usd_stage) -> None: docked=True, ) - asyncio.ensure_future(self._dock_viewport_async(self.cfg.viewport_name, dock_pos)) - self._create_and_assign_camera(usd_stage) + asyncio.ensure_future(self._dock_viewport_async(effective_viewport_name, dock_pos)) else: self._viewport_window = vp_utils.get_active_viewport_window() if self._viewport_window is None: logger.warning("[KitVisualizer] No active viewport window found.") self._viewport_api = None + self._refresh_controlled_camera_path() return self._viewport_api = self._viewport_window.viewport_api - # Pin the camera path we will write to, using the active camera at init time. - # This must happen before any _set_viewport_camera() call so the path is known. - self._controlled_camera_path = self._viewport_api.get_active_camera() or "/OmniverseKit_Persp" - if self.cfg.camera_source == "usd_path": - if not self._set_active_camera_path(self.cfg.camera_usd_path): - logger.warning( - "[KitVisualizer] camera_usd_path '%s' not found; using configured camera.", - self.cfg.camera_usd_path, + if self.cfg.cam_source == "prim_path": + if not self._set_active_camera_path(self.cfg.cam_prim_path): + raise RuntimeError( + "[KitVisualizer] cam_source='prim_path' requires a valid cam_prim_path. " + f"Camera prim not found: '{self.cfg.cam_prim_path}'." ) - self._set_viewport_camera(self.cfg.camera_position, self.cfg.camera_target) else: - self._set_viewport_camera(self.cfg.camera_position, self.cfg.camera_target) + self._apply_cfg_camera_pose_if_configured() + self._refresh_controlled_camera_path() + + def _refresh_controlled_camera_path(self) -> None: + """Cache :attr:`_controlled_camera_path` from the active viewport (or default persp).""" + if self._viewport_api is not None: + path = self._viewport_api.get_active_camera() + self._controlled_camera_path = path if path else "/OmniverseKit_Persp" + else: + self._controlled_camera_path = "/OmniverseKit_Persp" async def _dock_viewport_async(self, viewport_name: str, dock_position) -> None: """Dock a created viewport window relative to main viewport.""" @@ -303,35 +317,23 @@ async def _dock_viewport_async(self, viewport_name: str, dock_position) -> None: await omni.kit.app.get_app().next_update_async() viewport_window.focus() - def _create_and_assign_camera(self, usd_stage) -> None: - """Create viewport camera prim (if needed) and set it active.""" - camera_path = f"/World/Cameras/{self.cfg.viewport_name}_Camera".replace(" ", "_") - - camera_prim = usd_stage.GetPrimAtPath(camera_path) - if not camera_prim.IsValid(): - UsdGeom.Camera.Define(usd_stage, camera_path) - - if self._viewport_api: - self._viewport_api.set_active_camera(camera_path) - self._controlled_camera_path = camera_path - def _set_viewport_camera(self, position: tuple[float, float, float], target: tuple[float, float, float]) -> None: """Apply eye/target camera view to the active viewport.""" import isaacsim.core.utils.viewports as isaacsim_viewports - if self._viewport_api is None: - return - # Use the camera path pinned at initialization. This prevents user-switching the GUI - # viewport to a sensor camera from corrupting the sensor's USD prim transform. - camera_path = self._controlled_camera_path - if not camera_path: - camera_path = self._viewport_api.get_active_camera() if self._viewport_api else None + camera_path = None + if self._viewport_api is not None: + camera_path = self._viewport_api.get_active_camera() if not camera_path: camera_path = "/OmniverseKit_Persp" + kwargs = {"eye": list(position), "target": list(target), "camera_prim_path": camera_path} + if self._viewport_api is not None: + kwargs["viewport_api"] = self._viewport_api + isaacsim_viewports.set_camera_view(**kwargs) - isaacsim_viewports.set_camera_view( - eye=list(position), target=list(target), camera_prim_path=camera_path, viewport_api=self._viewport_api - ) + def _apply_cfg_camera_pose_if_configured(self) -> None: + """Apply configured camera pose from eye/lookat.""" + self._set_viewport_camera(self.cfg.eye, self.cfg.lookat) def _set_active_camera_path(self, camera_path: str) -> bool: """Set active camera path for viewport if the prim exists. @@ -348,7 +350,6 @@ def _set_active_camera_path(self, camera_path: str) -> bool: if not camera_prim.IsValid(): return False self._viewport_api.set_active_camera(camera_path) - self._controlled_camera_path = camera_path return True def _apply_env_visibility(self, usd_stage, metadata: dict) -> None: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py index 88112a6f20b4..342be3fc2c6f 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py @@ -5,6 +5,8 @@ """Configuration for Kit-based visualizer.""" +from __future__ import annotations + from isaaclab.utils import configclass from isaaclab.visualizers.visualizer_cfg import VisualizerCfg @@ -16,20 +18,23 @@ class KitVisualizerCfg(VisualizerCfg): visualizer_type: str = "kit" """Type identifier for Kit visualizer.""" - viewport_name: str | None = "Visualizer Viewport" - """Viewport name to use. If None, uses active viewport.""" + viewport_name: str | None = None + """Name for a new viewport window when :attr:`create_viewport` is ``True``. + + If ``None``, a default name (``"Visualizer Viewport"``) is used. + """ create_viewport: bool = False - """Create new viewport with specified name and camera pose.""" + """If ``True``, create a new viewport window; if ``False``, use the active viewport window.""" headless: bool = False """Run without creating viewport windows when supported by the app.""" dock_position: str = "SAME" - """Dock position for new viewport. Options: 'LEFT', 'RIGHT', 'BOTTOM', 'SAME'.""" + """Dock position for a new viewport. Options: 'LEFT', 'RIGHT', 'BOTTOM', 'SAME'.""" window_width: int = 1280 - """Viewport width in pixels.""" + """Viewport width in pixels (when :attr:`create_viewport` is ``True``).""" window_height: int = 720 - """Viewport height in pixels.""" + """Viewport height in pixels (when :attr:`create_viewport` is ``True``).""" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 9ffeb062065d..72172b1ecf7d 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -56,7 +56,7 @@ def __init__( self._fallback_draw_controls = True def is_training_paused(self) -> bool: - """Return whether training is paused by viewer controls.""" + """Return whether simulation is paused by viewer controls.""" return self._paused_training def is_rendering_paused(self) -> bool: @@ -68,7 +68,7 @@ def _render_training_controls(self, imgui): imgui.separator() imgui.text("IsaacLab Controls") - pause_label = "Resume Training" if self._paused_training else "Pause Training" + pause_label = "Resume Simulation" if self._paused_training else "Pause Simulation" if imgui.button(pause_label): self._paused_training = not self._paused_training @@ -110,7 +110,7 @@ def _render_ui(self): imgui.set_next_window_pos(imgui.ImVec2(320, 10)) flags = 0 - if imgui.begin("Training Controls", flags=flags): + if imgui.begin("Simulation Controls", flags=flags): self._render_training_controls(imgui) imgui.end() return None @@ -292,29 +292,27 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._model = scene_data_provider.get_newton_model() self._state = scene_data_provider.get_newton_state(self._env_ids) - try: - self._viewer = NewtonViewerGL( - width=self.cfg.window_width, - height=self.cfg.window_height, - headless=self.cfg.headless, - metadata=metadata, - update_frequency=self.cfg.update_frequency, - ) - except Exception as exc: - if not self.cfg.headless: - raise - self._viewer = None - self._headless_no_viewer = True - logger.info( - "[NewtonVisualizer] Headless fallback enabled (ViewerGL unavailable in this environment): %s", - exc, - ) + # Use pyglet's EGL headless backend when requested. Must run before the first + # ``pyglet.window`` import so ``Window`` resolves to :class:`~pyglet.window.headless.HeadlessWindow`. + if self.cfg.headless: + import pyglet + + pyglet.options["headless"] = True + + self._viewer = NewtonViewerGL( + width=self.cfg.window_width, + height=self.cfg.window_height, + headless=self.cfg.headless, + metadata=metadata, + update_frequency=self.cfg.update_frequency, + ) if self._viewer is not None: max_worlds = self.cfg.max_worlds self._viewer.set_model(self._model, max_worlds=max_worlds) self._viewer.set_world_offsets((0.0, 0.0, 0.0)) - self._apply_camera_pose(self._resolve_initial_camera_pose()) + initial_pose = self._resolve_initial_camera_pose() + self._apply_camera_pose(initial_pose) self._viewer.up_axis = 2 # Z-up self._viewer.scaling = 1.0 @@ -342,13 +340,11 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: title="NewtonVisualizer Configuration", rows=[ ( - "camera_position", - tuple(float(x) for x in self._viewer.camera.pos) - if self._viewer is not None - else self.cfg.camera_position, + "eye", + tuple(float(x) for x in self._viewer.camera.pos) if self._viewer is not None else self.cfg.eye, ), - ("camera_target", self._last_camera_pose[1] if self._last_camera_pose else self.cfg.camera_target), - ("camera_source", self.cfg.camera_source), + ("lookat", self._last_camera_pose[1] if self._last_camera_pose else self.cfg.lookat), + ("cam_source", self.cfg.cam_source), ("num_visualized_envs", num_visualized_envs), ("headless", self.cfg.headless), ], @@ -372,7 +368,7 @@ def step(self, dt: float) -> None: self._state = self._scene_data_provider.get_newton_state(self._env_ids) return - if self.cfg.camera_source == "usd_path": + if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() self._state = self._scene_data_provider.get_newton_state(self._env_ids) @@ -437,15 +433,15 @@ def _resolve_initial_camera_pose(self) -> tuple[tuple[float, float, float], tupl Returns: Camera eye and target tuples. """ - if self.cfg.camera_source == "usd_path": - pose = self._resolve_camera_pose_from_usd_path(self.cfg.camera_usd_path) + if self.cfg.cam_source == "prim_path": + pose = self._resolve_camera_pose_from_usd_path(self.cfg.cam_prim_path) if pose is not None: return pose - logger.warning( - "[NewtonVisualizer] camera_usd_path '%s' not found; using configured camera.", - self.cfg.camera_usd_path, + raise RuntimeError( + "[NewtonVisualizer] cam_source='prim_path' requires a resolvable camera prim path, " + f"but no camera pose was found for '{self.cfg.cam_prim_path}'." ) - return self.cfg.camera_position, self.cfg.camera_target + return self._resolve_cfg_camera_pose("NewtonVisualizer") def _apply_camera_pose(self, pose: tuple[tuple[float, float, float], tuple[float, float, float]]) -> None: """Apply camera eye/target pose to the Newton viewer. @@ -469,7 +465,7 @@ def _apply_camera_pose(self, pose: tuple[tuple[float, float, float], tuple[float def _update_camera_from_usd_path(self) -> None: """Refresh camera pose from configured USD camera path when it changes.""" - pose = self._resolve_camera_pose_from_usd_path(self.cfg.camera_usd_path) + pose = self._resolve_camera_pose_from_usd_path(self.cfg.cam_prim_path) if pose is None: return if self._last_camera_pose == pose: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py index ab2ed723223f..531b067104c1 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py @@ -188,7 +188,8 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._viewer.set_model(self._model, max_worlds=self.cfg.max_worlds) # Preserve simulation world positions (env_spacing) rather than adding viewer-side offsets. self._viewer.set_world_offsets((0.0, 0.0, 0.0)) - self._apply_camera_pose(self._resolve_initial_camera_pose()) + initial_pose = self._resolve_initial_camera_pose() + self._apply_camera_pose(initial_pose) self._viewer.up_axis = 2 self._viewer.scaling = 1.0 self._viewer._paused = False @@ -198,9 +199,9 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: logger=logger, title="RerunVisualizer Configuration", rows=[ - ("camera_position", self.cfg.camera_position), - ("camera_target", self.cfg.camera_target), - ("camera_source", self.cfg.camera_source), + ("eye", self.cfg.eye), + ("lookat", self.cfg.lookat), + ("cam_source", self.cfg.cam_source), ("num_visualized_envs", num_visualized_envs), ("endpoint", f"http://{viewer_host}:{web_port}"), ("viewer_url", viewer_url), @@ -227,7 +228,7 @@ def step(self, dt: float) -> None: self._sim_time += dt self._step_counter += 1 - if self.cfg.camera_source == "usd_path": + if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() self._state = self._scene_data_provider.get_newton_state(self._env_ids) @@ -275,11 +276,15 @@ def is_running(self) -> bool: def _resolve_initial_camera_pose(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: """Resolve initial camera pose from config or USD camera path.""" - if self.cfg.camera_source == "usd_path": - pose = self._resolve_camera_pose_from_usd_path(self.cfg.camera_usd_path) + if self.cfg.cam_source == "prim_path": + pose = self._resolve_camera_pose_from_usd_path(self.cfg.cam_prim_path) if pose is not None: return pose - return self.cfg.camera_position, self.cfg.camera_target + raise RuntimeError( + "[RerunVisualizer] cam_source='prim_path' requires a resolvable camera prim path, " + f"but no camera pose was found for '{self.cfg.cam_prim_path}'." + ) + return self._resolve_cfg_camera_pose("RerunVisualizer") def _apply_camera_pose(self, pose: tuple[tuple[float, float, float], tuple[float, float, float]]) -> None: """Apply camera pose to rerun's 3D view controls. @@ -307,7 +312,7 @@ def _apply_camera_pose(self, pose: tuple[tuple[float, float, float], tuple[float def _update_camera_from_usd_path(self) -> None: """Refresh camera pose from configured USD camera path when it changes.""" - pose = self._resolve_camera_pose_from_usd_path(self.cfg.camera_usd_path) + pose = self._resolve_camera_pose_from_usd_path(self.cfg.cam_prim_path) if pose is None: return if self._last_camera_pose == pose: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py index c20bfcd85a9f..d6606403bba6 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py @@ -162,9 +162,9 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: logger=logger, title="ViserVisualizer Configuration", rows=[ - ("camera_position", self.cfg.camera_position), - ("camera_target", self.cfg.camera_target), - ("camera_source", self.cfg.camera_source), + ("eye", self.cfg.eye), + ("lookat", self.cfg.lookat), + ("cam_source", self.cfg.cam_source), ("num_visualized_envs", num_visualized_envs), ("port", self.cfg.port), ("viewer_url", viewer_url), @@ -182,7 +182,7 @@ def step(self, dt: float) -> None: if not self._is_initialized or self._viewer is None or self._scene_data_provider is None: return - if self.cfg.camera_source == "usd_path": + if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() self._apply_pending_camera_pose() @@ -259,7 +259,8 @@ def _create_viewer(self, record_to_viser: str | None, metadata: dict | None = No self._viewer.set_world_offsets((0.0, 0.0, 0.0)) if self.cfg.open_browser: _open_viser_web_viewer(self.cfg.port) - self._set_viser_camera_view(self._resolve_initial_camera_pose()) + initial_pose = self._resolve_initial_camera_pose() + self._set_viser_camera_view(initial_pose) self._sim_time = 0.0 def _close_viewer(self, finalize_viser: bool = False) -> None: @@ -277,15 +278,15 @@ def _close_viewer(self, finalize_viser: bool = False) -> None: def _resolve_initial_camera_pose(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: """Resolve initial camera pose from config or USD camera path.""" - if self.cfg.camera_source == "usd_path": - pose = self._resolve_camera_pose_from_usd_path(self.cfg.camera_usd_path) + if self.cfg.cam_source == "prim_path": + pose = self._resolve_camera_pose_from_usd_path(self.cfg.cam_prim_path) if pose is not None: return pose - logger.warning( - "[ViserVisualizer] camera_usd_path '%s' not found; using configured camera.", - self.cfg.camera_usd_path, + raise RuntimeError( + "[ViserVisualizer] cam_source='prim_path' requires a resolvable camera prim path, " + f"but no camera pose was found for '{self.cfg.cam_prim_path}'." ) - return self.cfg.camera_position, self.cfg.camera_target + return self._resolve_cfg_camera_pose("ViserVisualizer") def _try_apply_viser_camera_view(self, pose: tuple[tuple[float, float, float], tuple[float, float, float]]) -> bool: """Try applying camera pose to active viser clients. @@ -341,7 +342,7 @@ def _apply_pending_camera_pose(self) -> None: def _update_camera_from_usd_path(self) -> None: """Refresh camera pose from configured USD camera path when it changes.""" - pose = self._resolve_camera_pose_from_usd_path(self.cfg.camera_usd_path) + pose = self._resolve_camera_pose_from_usd_path(self.cfg.cam_prim_path) if pose is None: return if self._last_camera_pose == pose or self._pending_camera_pose == pose: diff --git a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py new file mode 100644 index 000000000000..9b7244903425 --- /dev/null +++ b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py @@ -0,0 +1,595 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Integration tests: cartpole env + per-backend visualizers (Kit Replicator, tiled camera, GL, Rerun, Viser). + +Visualizer packages use ``logging.getLogger(__name__)``, so loggers are named like +``isaaclab_visualizers.kit.kit_visualizer`` and ``isaaclab.visualizers.base_visualizer``. +:class:`~isaaclab.sim.simulation_context.SimulationContext` uses +``logging.getLogger(__name__)`` → ``isaaclab.sim.simulation_context``. + +We filter :class:`~pytest.LogCaptureFixture` records with :data:`_VIS_LOGGER_PREFIXES` +so only those namespaces count (not Omniverse, PhysX, or unrelated warnings). + +Set :data:`ASSERT_VISUALIZER_WARNINGS` to ``True`` locally or in CI if you want tests to +fail on WARNING-level records from those loggers; by default only ERROR+ fails. +""" + +from __future__ import annotations + +# Pyglet must use HeadlessWindow (EGL) before ``pyglet.window`` is imported so Newton +# ViewerGL can construct without an X11 display (matches ``headless=True`` on NewtonVisualizerCfg). +import pyglet + +pyglet.options["headless"] = True + +from isaaclab.app import AppLauncher + +# launch Kit app +simulation_app = AppLauncher(headless=True, enable_cameras=True).app + +import contextlib +import copy +import logging +import socket + +import numpy as np +import pytest +import torch +import warp as wp +from isaaclab_visualizers.kit import KitVisualizer, KitVisualizerCfg +from isaaclab_visualizers.newton import NewtonVisualizer, NewtonVisualizerCfg +from isaaclab_visualizers.rerun import RerunVisualizer, RerunVisualizerCfg +from isaaclab_visualizers.viser import ViserVisualizer, ViserVisualizerCfg + +import isaaclab.sim as sim_utils +from isaaclab.sim import SimulationContext + +from isaaclab_tasks.direct.cartpole.cartpole_camera_env import CartpoleCameraEnv +from isaaclab_tasks.direct.cartpole.cartpole_camera_presets_env_cfg import CartpoleCameraPresetsEnvCfg +from isaaclab_tasks.manager_based.classic.cartpole.cartpole_env_cfg import CartpolePhysicsCfg + +# When True, tests also fail on WARNING-level records from visualizer-related loggers. +ASSERT_VISUALIZER_WARNINGS = False + +_MAX_NON_BLACK_STEPS = 8 +"""Steps for tiled camera / Rerun / Viser smoke tests (early exit ok when non-black).""" + +_CARTPOLE_INTEGRATION_NUM_ENVS = 1 +"""Vectorized env count for cartpole + visualizer integration tests.""" + +_CARTPOLE_INTEGRATION_VISUALIZER_EYE: tuple[float, float, float] = (3.0, 3.0, 3.0) +"""Passed to :class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` subclasses (``eye``).""" + +_CARTPOLE_INTEGRATION_VISUALIZER_LOOKAT: tuple[float, float, float] = (-4.0, -4.0, 0.0) +"""Passed to visualizer cfgs (``lookat``); also applied to :class:`~isaaclab.envs.common.ViewerCfg` for the env.""" + +# Resolution overrides for this test module (cartpole preset defaults: tiled camera 100×100; Kit helper was 320×240). +_CARTPOLE_KIT_INTEGRATION_RENDER_RESOLUTION: tuple[int, int] = (600, 600) +"""Kit: Replicator ``render_product`` (width, height) for viewport RGB in the motion check.""" + +_CARTPOLE_NEWTON_INTEGRATION_WINDOW_SIZE: tuple[int, int] = (600, 600) +"""Newton: ``NewtonVisualizerCfg`` framebuffer (window_width × window_height) for ``get_frame()``.""" + +_CARTPOLE_TILED_CAMERA_INTEGRATION_WH: tuple[int, int] = (600, 600) +"""Tiled camera per-env tile width/height (preset default is 100×100); keeps ``observation_space`` consistent.""" + +_VIS_FRAME_TEST_STEPS = 60 +"""Steps for Kit / Newton frame capture: no early exit.""" + +# Motion check compares the 2nd vs last captured frame (e.g. 2nd vs 60th when *_STEPS* is 60). +_MOTION_FRAME_EARLY_IDX = 1 +"""0-based index of the *early* frame (2nd capture).""" + +_MOTION_FRAME_LATE_IDX = _VIS_FRAME_TEST_STEPS - 1 +"""0-based index of the *late* frame (e.g. 60th capture when :data:`_VIS_FRAME_TEST_STEPS` is 60).""" + +# Early vs late frame motion: void background stays similar; only count *strongly* differing pixels. +_FRAME_MOTION_CHANNEL_DIFF_THRESHOLD = 50 +"""A pixel counts as differing if max(|ΔR|, |ΔG|, |ΔB|) >= this (0–255 space).""" + +_FRAME_MOTION_MIN_DIFFERING_PIXELS = 100 +"""Minimum number of such pixels between early and late frames (stale/frozen viz should be near zero).""" + +_VIS_LOGGER_PREFIXES = ( + "isaaclab.visualizers", + "isaaclab_visualizers", + "isaaclab.sim.simulation_context", +) + + +def _logger_name_matches_visualizer_scope(logger_name: str) -> bool: + """Return True if *logger_name* is a visualizer / SimulationContext visualizer path.""" + return any(logger_name.startswith(prefix) for prefix in _VIS_LOGGER_PREFIXES) + + +def _assert_no_visualizer_log_issues(caplog: pytest.LogCaptureFixture, *, fail_on_warnings: bool | None = None) -> None: + """Fail if captured records include ERROR/CRITICAL (always) or WARNING (if *fail_on_warnings*). + + *fail_on_warnings* defaults to :data:`ASSERT_VISUALIZER_WARNINGS`. + """ + if fail_on_warnings is None: + fail_on_warnings = ASSERT_VISUALIZER_WARNINGS + + error_logs = [ + r for r in caplog.records if r.levelno >= logging.ERROR and _logger_name_matches_visualizer_scope(r.name) + ] + assert not error_logs, "Visualizer-related error logs: " + "; ".join( + f"{r.name}: {r.getMessage()}" for r in error_logs + ) + + if fail_on_warnings: + warning_logs = [ + r for r in caplog.records if r.levelno == logging.WARNING and _logger_name_matches_visualizer_scope(r.name) + ] + assert not warning_logs, "Visualizer-related warning logs: " + "; ".join( + f"{r.name}: {r.getMessage()}" for r in warning_logs + ) + + +def _configure_sim_for_visualizer_test(env: CartpoleCameraEnv) -> None: + """Settings used by the previous smoke tests; keep RTX sensors enabled for camera paths.""" + env.sim.set_setting("/isaaclab/render/rtx_sensors", True) + env.sim._app_control_on_stop_handle = None # type: ignore[attr-defined] + + +def _find_free_tcp_port(host: str = "127.0.0.1") -> int: + """Ask OS for a currently free local TCP port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((host, 0)) + return int(sock.getsockname()[1]) + + +def _allocate_rerun_test_ports(host: str = "127.0.0.1") -> tuple[int, int]: + """Allocate distinct free ports for rerun web and gRPC endpoints.""" + grpc_port = _find_free_tcp_port(host) + web_port = _find_free_tcp_port(host) + while web_port == grpc_port: + web_port = _find_free_tcp_port(host) + return web_port, grpc_port + + +def _cartpole_integration_visualizer_camera_kwargs() -> dict[str, tuple[float, float, float]]: + """Eye/lookat for all :class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` subclasses in these tests.""" + return { + "eye": _CARTPOLE_INTEGRATION_VISUALIZER_EYE, + "lookat": _CARTPOLE_INTEGRATION_VISUALIZER_LOOKAT, + } + + +def _get_visualizer_cfg(visualizer_kind: str): + """Return (visualizer_cfg, expected_visualizer_cls) for the given visualizer kind.""" + cam = _cartpole_integration_visualizer_camera_kwargs() + if visualizer_kind == "newton": + __import__("newton") + nw, nh = _CARTPOLE_NEWTON_INTEGRATION_WINDOW_SIZE + return NewtonVisualizerCfg(headless=True, window_width=nw, window_height=nh, **cam), NewtonVisualizer + if visualizer_kind == "viser": + __import__("newton") + __import__("viser") + port = _find_free_tcp_port(host="127.0.0.1") + return ViserVisualizerCfg(open_browser=False, port=port, **cam), ViserVisualizer + if visualizer_kind == "rerun": + __import__("newton") + __import__("rerun") + web_port, grpc_port = _allocate_rerun_test_ports(host="127.0.0.1") + return ( + RerunVisualizerCfg( + bind_address="127.0.0.1", + open_browser=False, + web_port=web_port, + grpc_port=grpc_port, + **cam, + ), + RerunVisualizer, + ) + return KitVisualizerCfg(**cam), KitVisualizer + + +def _get_physics_cfg(backend_kind: str): + """Return physics config and expected backend substring for the given backend kind.""" + if backend_kind == "physx": + __import__("isaaclab_physx") + preset = CartpolePhysicsCfg() + physics_cfg = getattr(preset, "physx", None) + if physics_cfg is None: + from isaaclab_physx.physics import PhysxCfg + + physics_cfg = PhysxCfg() + return physics_cfg, "physx" + if backend_kind == "newton": + __import__("newton") + __import__("isaaclab_newton") + preset = CartpolePhysicsCfg() + physics_cfg = getattr(preset, "newton", None) + if physics_cfg is None: + from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg + + physics_cfg = NewtonCfg( + solver_cfg=MJWarpSolverCfg( + njmax=5, + nconmax=3, + cone="pyramidal", + impratio=1, + integrator="implicitfast", + ), + num_substeps=1, + debug_mode=False, + use_cuda_graph=True, + ) + return physics_cfg, "newton" + raise ValueError(f"Unknown backend: {backend_kind!r}") + + +def _assert_non_black_tensor(image_tensor: torch.Tensor, *, min_nonzero_pixels: int = 1) -> None: + """Assert camera-like tensor contains non-black pixels.""" + assert isinstance(image_tensor, torch.Tensor), f"Expected torch.Tensor, got {type(image_tensor)!r}" + assert image_tensor.numel() > 0, "Image tensor is empty." + finite_tensor = torch.where(torch.isfinite(image_tensor), image_tensor, torch.zeros_like(image_tensor)) + if finite_tensor.dtype.is_floating_point: + nonzero = torch.count_nonzero(torch.abs(finite_tensor) > 1e-6).item() + else: + nonzero = torch.count_nonzero(finite_tensor > 0).item() + assert nonzero >= min_nonzero_pixels, "Rendered frame appears black (no non-zero pixels)." + + +def _frame_to_numpy(frame) -> np.ndarray: + """Convert viewer ``get_frame()`` output (numpy, torch, or Warp array) to host ``numpy.ndarray``. + + ``np.asarray(wp.array)`` is unsafe: NumPy can trigger Warp indexing that raises at dimension edges. + """ + if isinstance(frame, np.ndarray): + return frame + if isinstance(frame, torch.Tensor): + return frame.detach().cpu().numpy() + if isinstance(frame, wp.array): + return wp.to_torch(frame).detach().cpu().numpy() + return np.asarray(frame) + + +def _assert_non_black_frame_array(frame) -> None: + """Assert viewer-captured frame has visible, non-black content.""" + frame_arr = _frame_to_numpy(frame) + assert frame_arr.size > 0, "Viewer returned an empty frame." + if frame_arr.ndim == 2: + color = frame_arr + else: + assert frame_arr.shape[-1] >= 3, f"Expected at least 3 channels, got shape {frame_arr.shape}." + color = frame_arr[..., :3] + finite = np.where(np.isfinite(color), color, 0) + assert np.count_nonzero(finite) > 0, "Viewer frame appears fully black." + + +def _frame_rgb_255_space(frame) -> np.ndarray: + """Return HxWx3 float in ~0–255 space for per-channel differencing.""" + arr = _frame_to_numpy(frame) + if arr.ndim == 2: + rgb = np.stack([arr, arr, arr], axis=-1) + else: + rgb = arr[..., :3] + rgb = np.asarray(rgb, dtype=np.float64) + # Normalized HDR buffers: scale so threshold matches (0,255) semantics. + if rgb.size > 0 and float(np.nanmax(rgb)) <= 1.0 + 1e-6: + rgb = rgb * 255.0 + return rgb + + +def _count_significantly_differing_pixels( + frame_a, + frame_b, + *, + channel_diff_threshold: float = _FRAME_MOTION_CHANNEL_DIFF_THRESHOLD, +) -> int: + """Count pixels where max(|ΔR|, |ΔG|, |ΔB|) >= *channel_diff_threshold* (0–255 space).""" + a = _frame_rgb_255_space(frame_a) + b = _frame_rgb_255_space(frame_b) + assert a.shape == b.shape, f"Frame shape mismatch for motion check: {a.shape} vs {b.shape}." + per_pixel_max = np.max(np.abs(a - b), axis=-1) + return int(np.count_nonzero(per_pixel_max >= channel_diff_threshold)) + + +def _assert_early_and_late_motion_frames_differ( + frames: list, + *, + channel_diff_threshold: float = _FRAME_MOTION_CHANNEL_DIFF_THRESHOLD, + min_differing_pixels: int = _FRAME_MOTION_MIN_DIFFERING_PIXELS, +) -> None: + """Fail if early vs late frames lack enough strongly differing pixels (stale/frozen bodies). + + Compares :data:`_MOTION_FRAME_EARLY_IDX` vs :data:`_MOTION_FRAME_LATE_IDX` (e.g. 2nd vs 60th capture). + + Voids/background stay near-identical; we only count pixels that change by at least + *channel_diff_threshold* on some channel (0–255). + """ + assert len(frames) >= _VIS_FRAME_TEST_STEPS, ( + f"Need at least {_VIS_FRAME_TEST_STEPS} frames for motion check, got {len(frames)}." + ) + i_early = _MOTION_FRAME_EARLY_IDX + i_late = _MOTION_FRAME_LATE_IDX + early_1 = i_early + 1 + late_1 = i_late + 1 + n_diff = _count_significantly_differing_pixels( + frames[i_early], frames[i_late], channel_diff_threshold=channel_diff_threshold + ) + assert n_diff >= min_differing_pixels, ( + f"Viewport captures #{early_1} and #{late_1} have too few strongly differing pixels " + f"({n_diff} < {min_differing_pixels}; threshold per channel={channel_diff_threshold} in 0–255 space). " + "Possible frozen or stale robot visualization." + ) + + +def _step_until_non_black_camera(env, actions: torch.Tensor, *, max_steps: int = _MAX_NON_BLACK_STEPS) -> None: + """Step env until the env's tiled camera RGB tensor is non-black, bounded by *max_steps*.""" + last_rgb = None + for _ in range(max_steps): + env.step(action=actions) + rgb = env._tiled_camera.data.output.get("rgb") + if rgb is None: + rgb = env._tiled_camera.data.output[env.cfg.tiled_camera.data_types[0]] + last_rgb = rgb + try: + _assert_non_black_tensor(rgb) + return + except AssertionError: + continue + _assert_non_black_tensor(last_rgb) + + +def _run_newton_viewer_frame_motion_test( + viewer, + *, + step_hook, + physics_kind: str, + viz_kind: str = "newton", +) -> None: + """Exactly ``_VIS_FRAME_TEST_STEPS`` sim steps; last frame non-black; early vs late motion check.""" + frames: list = [] + for _ in range(_VIS_FRAME_TEST_STEPS): + step_hook() + frames.append(viewer.get_frame()) + _assert_non_black_frame_array(frames[-1]) + _assert_early_and_late_motion_frames_differ(frames) + + +def _step_env_without_frame_check(env, actions: torch.Tensor, *, max_steps: int = _MAX_NON_BLACK_STEPS) -> None: + """Step the env to exercise visualizers that do not implement ``get_frame`` (e.g. Rerun, Viser).""" + for _ in range(max_steps): + env.step(action=actions) + + +def _build_rgb_annotator_for_camera( + camera_path: str, + *, + resolution: tuple[int, int] | None = None, +): + """Create CPU RGB annotator attached to a camera render product.""" + import omni.replicator.core as rep + + if resolution is None: + resolution = _CARTPOLE_KIT_INTEGRATION_RENDER_RESOLUTION + render_product = rep.create.render_product(camera_path, resolution=resolution) + annotator = rep.AnnotatorRegistry.get_annotator("rgb", device="cpu") + annotator.attach([render_product]) + return annotator, render_product + + +def _annotator_rgb_to_numpy(rgb_data) -> np.ndarray: + """Convert replicator annotator output to HxWx3 uint8 numpy array.""" + rgb_array = np.frombuffer(rgb_data, dtype=np.uint8).reshape(*rgb_data.shape) + if rgb_array.size == 0: + return np.zeros((1, 1, 3), dtype=np.uint8) + return rgb_array[:, :, :3] + + +def _run_kit_viewport_frame_motion_test( + env, + kit_visualizer: KitVisualizer, + *, + physics_kind: str, + viz_kind: str = "kit", +) -> None: + """Exactly ``_VIS_FRAME_TEST_STEPS`` env steps; last Replicator frame non-black; early vs late motion check.""" + camera_path = getattr(kit_visualizer, "_controlled_camera_path", None) + assert camera_path, "Kit visualizer does not expose a controlled viewport camera path." + + annotator = None + render_product = None + try: + annotator, render_product = _build_rgb_annotator_for_camera(camera_path) + actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device) + frames: list = [] + for _ in range(_VIS_FRAME_TEST_STEPS): + env.step(action=actions) + rgb_data = annotator.get_data() + frames.append(_annotator_rgb_to_numpy(rgb_data)) + _assert_non_black_frame_array(frames[-1]) + _assert_early_and_late_motion_frames_differ(frames) + finally: + if annotator is not None and render_product is not None: + with contextlib.suppress(Exception): + annotator.detach([render_product]) + + +def _make_cartpole_camera_env(visualizer_kind: str, backend_kind: str) -> CartpoleCameraEnv: + """Create cartpole camera env configured with selected visualizer and physics backend.""" + env_cfg_root = CartpoleCameraPresetsEnvCfg() + env_cfg = getattr(env_cfg_root, "default", None) + if env_cfg is None: + env_cfg = getattr(type(env_cfg_root), "default", None) + if env_cfg is None: + raise RuntimeError( + "CartpoleCameraPresetsEnvCfg does not expose a 'default' preset config. " + f"Available attributes: {sorted(vars(env_cfg_root).keys())}" + ) + env_cfg = copy.deepcopy(env_cfg) + env_cfg.scene.num_envs = _CARTPOLE_INTEGRATION_NUM_ENVS + env_cfg.viewer.eye = _CARTPOLE_INTEGRATION_VISUALIZER_EYE + env_cfg.viewer.lookat = _CARTPOLE_INTEGRATION_VISUALIZER_LOOKAT + tw, th = _CARTPOLE_TILED_CAMERA_INTEGRATION_WH + env_cfg.tiled_camera.width = tw + env_cfg.tiled_camera.height = th + if isinstance(env_cfg.observation_space, list) and len(env_cfg.observation_space) >= 3: + env_cfg.observation_space = [th, tw, env_cfg.observation_space[2]] + env_cfg.seed = None + env_cfg.sim.physics, _ = _get_physics_cfg(backend_kind) + visualizer_cfg, _ = _get_visualizer_cfg(visualizer_kind) + env_cfg.sim.visualizer_cfgs = visualizer_cfg + return CartpoleCameraEnv(env_cfg) + + +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize( + "backend_kind", + [ + # xfail: Kit visualizer + PhysX only (Newton backend uses skip below — separate CUDA issue). + pytest.param( + "physx", + marks=pytest.mark.xfail( + reason=("Kit visualizer + PhysX: TODO remove xfail when stale Fabric transforms bug in Kit is fixed"), + strict=False, + ), + ), + pytest.param( + "newton", + marks=pytest.mark.skip( + reason=( + "TODO: Kit visualizer + Newton physics + Isaac RTX tiled camera can hit CUDA illegal access " + "or bad GPU state. Repro: rl_games train Isaac-Cartpole-Camera-Presets-Direct-v0 " + "--enable_cameras presets=newton --viz kit. Re-enable when fixed." + ) + ), + ), + ], +) +def test_cartpole_kit_visualizer_replicator_viewport_rgb_motion( + backend_kind: str, caplog: pytest.LogCaptureFixture +) -> None: + """Kit + cartpole: Replicator RGB on viewport camera; last frame non-black; early vs late frame differ; logs.""" + env = None + try: + sim_utils.create_new_stage() + env = _make_cartpole_camera_env(visualizer_kind="kit", backend_kind=backend_kind) + _configure_sim_for_visualizer_test(env) + with caplog.at_level(logging.WARNING): + env.reset() + kit_visualizers = [viz for viz in env.sim.visualizers if isinstance(viz, KitVisualizer)] + assert kit_visualizers, "Expected an initialized Kit visualizer." + _run_kit_viewport_frame_motion_test(env, kit_visualizers[0], physics_kind=backend_kind) + _assert_no_visualizer_log_issues(caplog) + finally: + if env is not None: + env.close() + else: + SimulationContext.clear_instance() + + +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize("backend_kind", ["physx", "newton"]) +def test_cartpole_newton_visualizer_tiled_camera_rgb_non_black( + backend_kind: str, caplog: pytest.LogCaptureFixture +) -> None: + """Newton visualizer + cartpole: env tiled-camera RGB becomes non-black within a few steps; clean logs.""" + env = None + try: + sim_utils.create_new_stage() + env = _make_cartpole_camera_env(visualizer_kind="newton", backend_kind=backend_kind) + _configure_sim_for_visualizer_test(env) + with caplog.at_level(logging.WARNING): + env.reset() + actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device) + _step_until_non_black_camera(env, actions, max_steps=_MAX_NON_BLACK_STEPS) + _assert_no_visualizer_log_issues(caplog) + finally: + if env is not None: + env.close() + else: + SimulationContext.clear_instance() + + +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize("backend_kind", ["physx", "newton"]) +def test_cartpole_newton_visualizer_viewergl_rgb_motion(backend_kind: str, caplog: pytest.LogCaptureFixture) -> None: + """Newton GL (``ViewerGL.get_frame``): full motion steps, last frame non-black; early vs late differ; logs.""" + env = None + try: + sim_utils.create_new_stage() + env = _make_cartpole_camera_env(visualizer_kind="newton", backend_kind=backend_kind) + _configure_sim_for_visualizer_test(env) + with caplog.at_level(logging.WARNING): + env.reset() + actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device) + newton_visualizers = [viz for viz in env.sim.visualizers if isinstance(viz, NewtonVisualizer)] + assert newton_visualizers, "Expected an initialized Newton visualizer." + viewer = getattr(newton_visualizers[0], "_viewer", None) + assert viewer is not None, "Newton viewer was not created." + + def _step_env() -> None: + env.step(action=actions) + + _run_newton_viewer_frame_motion_test(viewer, step_hook=_step_env, physics_kind=backend_kind) + _assert_no_visualizer_log_issues(caplog) + finally: + if env is not None: + env.close() + else: + SimulationContext.clear_instance() + + +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize("backend_kind", ["physx", "newton"]) +def test_cartpole_rerun_visualizer_smoke_steps_and_logs(backend_kind: str, caplog: pytest.LogCaptureFixture) -> None: + """Rerun + cartpole: visualizer and viewer initialize; env steps exercise the pipeline; clean logs. + + Rerun does not expose a per-frame RGB API like ``get_frame``, so we do not assert pixel content. + """ + env = None + try: + sim_utils.create_new_stage() + env = _make_cartpole_camera_env(visualizer_kind="rerun", backend_kind=backend_kind) + _configure_sim_for_visualizer_test(env) + with caplog.at_level(logging.WARNING): + env.reset() + actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device) + rerun_visualizers = [viz for viz in env.sim.visualizers if isinstance(viz, RerunVisualizer)] + assert rerun_visualizers, "Expected an initialized Rerun visualizer." + assert getattr(rerun_visualizers[0], "_viewer", None) is not None, "Rerun viewer was not created." + _step_env_without_frame_check(env, actions, max_steps=_MAX_NON_BLACK_STEPS) + _assert_no_visualizer_log_issues(caplog) + finally: + if env is not None: + env.close() + else: + SimulationContext.clear_instance() + + +@pytest.mark.isaacsim_ci +@pytest.mark.parametrize("backend_kind", ["physx", "newton"]) +def test_cartpole_viser_visualizer_smoke_steps_and_logs(backend_kind: str, caplog: pytest.LogCaptureFixture) -> None: + """Viser + cartpole: visualizer and viewer initialize; env steps exercise the pipeline; clean logs. + + No per-frame RGB assertion (Viser does not mirror the Newton ``get_frame`` path used elsewhere). + """ + env = None + try: + sim_utils.create_new_stage() + env = _make_cartpole_camera_env(visualizer_kind="viser", backend_kind=backend_kind) + _configure_sim_for_visualizer_test(env) + with caplog.at_level(logging.WARNING): + env.reset() + actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device) + viser_visualizers = [viz for viz in env.sim.visualizers if isinstance(viz, ViserVisualizer)] + assert viser_visualizers, "Expected an initialized Viser visualizer." + assert getattr(viser_visualizers[0], "_viewer", None) is not None, "Viser viewer was not created." + _step_env_without_frame_check(env, actions, max_steps=_MAX_NON_BLACK_STEPS) + _assert_no_visualizer_log_issues(caplog) + finally: + if env is not None: + env.close() + else: + SimulationContext.clear_instance() + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--maxfail=1"]) diff --git a/source/isaaclab_visualizers/test/test_visualizer_smoke_logs.py b/source/isaaclab_visualizers/test/test_visualizer_smoke_logs.py deleted file mode 100644 index 22f620fb02a8..000000000000 --- a/source/isaaclab_visualizers/test/test_visualizer_smoke_logs.py +++ /dev/null @@ -1,228 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Smoke test visualizer stepping and error logging.""" - -from isaaclab.app import AppLauncher - -# launch Kit app -simulation_app = AppLauncher(headless=True, enable_cameras=True).app - -import logging -import socket - -import pytest -import torch -from isaaclab_visualizers.kit import KitVisualizer, KitVisualizerCfg -from isaaclab_visualizers.newton import NewtonVisualizer, NewtonVisualizerCfg -from isaaclab_visualizers.rerun import RerunVisualizer, RerunVisualizerCfg -from isaaclab_visualizers.viser import ViserVisualizer, ViserVisualizerCfg - -import isaaclab.sim as sim_utils -from isaaclab.envs import DirectRLEnv, DirectRLEnvCfg -from isaaclab.scene import InteractiveSceneCfg -from isaaclab.sim import SimulationCfg, SimulationContext -from isaaclab.utils import configclass - -from isaaclab_tasks.manager_based.classic.cartpole.cartpole_env_cfg import ( - CartpolePhysicsCfg, - CartpoleSceneCfg, -) - -# Set to False to only fail on visualizer errors; when True, also fail on warnings. -ASSERT_VISUALIZER_WARNINGS = True - -_SMOKE_STEPS = 4 -_VIS_LOGGER_PREFIXES = ( - "isaaclab.visualizers", - "isaaclab_visualizers", - "isaaclab.sim.simulation_context", -) - - -def _find_free_tcp_port(host: str = "127.0.0.1") -> int: - """Ask OS for a currently free local TCP port.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind((host, 0)) - return int(sock.getsockname()[1]) - - -def _allocate_rerun_test_ports(host: str = "127.0.0.1") -> tuple[int, int]: - """Allocate distinct free ports for rerun web and gRPC endpoints.""" - grpc_port = _find_free_tcp_port(host) - web_port = _find_free_tcp_port(host) - while web_port == grpc_port: - web_port = _find_free_tcp_port(host) - return web_port, grpc_port - - -@configclass -class _SmokeEnvCfg(DirectRLEnvCfg): - decimation: int = 2 - action_space: int = 0 - observation_space: int = 0 - episode_length_s: float = 5.0 - sim: SimulationCfg = SimulationCfg(dt=0.005, render_interval=2, visualizer_cfgs=KitVisualizerCfg()) - scene: InteractiveSceneCfg = InteractiveSceneCfg(num_envs=1, env_spacing=1.0) - - -class _SmokeEnv(DirectRLEnv): - def _pre_physics_step(self, actions): - return - - def _apply_action(self): - return - - def _get_observations(self): - return {} - - def _get_rewards(self): - return {} - - def _get_dones(self): - return torch.zeros(1, dtype=torch.bool), torch.zeros(1, dtype=torch.bool) - - -def _get_visualizer_cfg(visualizer_kind: str): - """Return (visualizer_cfg, expected_visualizer_cls) for the given visualizer kind.""" - if visualizer_kind == "newton": - __import__("newton") - return NewtonVisualizerCfg(headless=True), NewtonVisualizer - if visualizer_kind == "viser": - __import__("newton") - __import__("viser") - return ViserVisualizerCfg(open_browser=False), ViserVisualizer - if visualizer_kind == "rerun": - __import__("newton") - __import__("rerun") - web_port, grpc_port = _allocate_rerun_test_ports(host="127.0.0.1") - # Use dynamically allocated non-default ports in smoke tests to avoid collisions. - # TODO: Consider supporting cleanup/termination of stale rerun processes when ports are occupied. - return ( - RerunVisualizerCfg( - bind_address="127.0.0.1", - open_browser=False, - web_port=web_port, - grpc_port=grpc_port, - ), - RerunVisualizer, - ) - return KitVisualizerCfg(), KitVisualizer - - -def _get_physics_cfg(backend_kind: str): - """Return physics config and expected backend substring for the given backend kind. - - Uses cartpole preset instance so we work whether presets are class or instance attributes. - Fallback: build PhysxCfg/NewtonCfg in-test if preset does not expose that backend. - """ - if backend_kind == "physx": - __import__("isaaclab_physx") - preset = CartpolePhysicsCfg() - physics_cfg = getattr(preset, "physx", None) - if physics_cfg is None: - from isaaclab_physx.physics import PhysxCfg - - physics_cfg = PhysxCfg() - return physics_cfg, "physx" - if backend_kind == "newton": - __import__("newton") - __import__("isaaclab_newton") - preset = CartpolePhysicsCfg() - physics_cfg = getattr(preset, "newton", None) - if physics_cfg is None: - from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg - - physics_cfg = NewtonCfg( - solver_cfg=MJWarpSolverCfg( - njmax=5, - nconmax=3, - cone="pyramidal", - impratio=1, - integrator="implicitfast", - ), - num_substeps=1, - debug_mode=False, - use_cuda_graph=True, - ) - return physics_cfg, "newton" - raise ValueError(f"Unknown backend: {backend_kind!r}") - - -def _resolve_case(visualizer_kind: str, backend_kind: str): - """Resolve (env_cfg, expected_visualizer_cls, expected_backend_substring) for one smoke test. - - Uses cartpole scene for all combinations (works with both PhysX and Newton). - """ - scene_cfg = CartpoleSceneCfg(num_envs=1, env_spacing=1.0) - viz_cfg, expected_viz_cls = _get_visualizer_cfg(visualizer_kind) - physics_cfg, expected_backend = _get_physics_cfg(backend_kind) - - cfg = _SmokeEnvCfg() - cfg.scene = scene_cfg - cfg.sim = SimulationCfg( - dt=0.005, - render_interval=2, - visualizer_cfgs=viz_cfg, - physics=physics_cfg, - ) - return cfg, expected_viz_cls, expected_backend - - -def _run_smoke_test(cfg, expected_visualizer_cls, expected_backend: str, caplog) -> None: - """Run smoke steps and assert no visualizer errors; optionally no warnings (see ASSERT_VISUALIZER_WARNINGS).""" - env = None - try: - sim_utils.create_new_stage() - env = _SmokeEnv(cfg=cfg) - backend_name = env.sim.physics_manager.__name__.lower() - assert expected_backend in backend_name, ( - f"Expected physics backend containing {expected_backend!r}, got {backend_name!r}" - ) - env.sim.set_setting("/isaaclab/render/rtx_sensors", True) - env.sim._app_control_on_stop_handle = None # type: ignore[attr-defined] - - actions = torch.zeros((env.num_envs, 0), device=env.device) - with caplog.at_level(logging.WARNING): - env.reset() - assert env.sim.visualizers - assert isinstance(env.sim.visualizers[0], expected_visualizer_cls) - for _ in range(_SMOKE_STEPS): - env.step(action=actions) - - # Always fail on errors - error_logs = [ - r for r in caplog.records if r.levelno >= logging.ERROR and r.name.startswith(_VIS_LOGGER_PREFIXES) - ] - assert not error_logs, "Visualizer emitted error logs during smoke stepping: " + "; ".join( - f"{r.name}: {r.message}" for r in error_logs - ) - - # Optionally fail on warnings - if ASSERT_VISUALIZER_WARNINGS: - warning_logs = [ - r for r in caplog.records if r.levelno >= logging.WARNING and r.name.startswith(_VIS_LOGGER_PREFIXES) - ] - assert not warning_logs, "Visualizer emitted warning logs during smoke stepping: " + "; ".join( - f"{r.name}: {r.message}" for r in warning_logs - ) - finally: - if env is not None: - env.close() - else: - SimulationContext.clear_instance() - - -@pytest.mark.isaacsim_ci -@pytest.mark.parametrize("visualizer_kind", ["kit", "newton", "rerun", "viser"]) -@pytest.mark.parametrize("backend_kind", ["physx", "newton"]) -def test_visualizer_backend_smoke(visualizer_kind: str, backend_kind: str, caplog): - """Smoke test each (visualizer, backend) pair; assert no errors (optionally no warnings).""" - cfg, expected_viz_cls, expected_backend = _resolve_case(visualizer_kind, backend_kind) - _run_smoke_test(cfg, expected_viz_cls, expected_backend, caplog) - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "--maxfail=1"]) From 97c4d2850d6d140c84f058bf63a8e0d28b56a920 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Thu, 2 Apr 2026 22:38:58 +0000 Subject: [PATCH 02/37] init bug fix for stale renderer images after reset w/o kit viz --- source/isaaclab/isaaclab/sim/simulation_context.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index fa5427bcb24f..aa2d80eaa7c1 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -349,7 +349,13 @@ def has_offscreen_render(self) -> bool: def has_active_visualizers(self) -> bool: """Return whether any visualizer path is active for rendering/camera control.""" - return bool(self.get_setting("/isaaclab/visualizer/types")) + return bool(self.get_setting("/isaaclab/visualizer/types")) or bool( + self.get_setting("/isaaclab/video/auto_start_kit") + ) + + def can_render_rgb_array(self) -> bool: + """Return whether rgb-array rendering is currently available.""" + return self.has_gui or self.has_offscreen_render or self.has_active_visualizers() @property def is_rendering(self) -> bool: From d849fbfa45106e967ee7526c593836ee711510d9 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Tue, 14 Apr 2026 00:25:54 +0000 Subject: [PATCH 03/37] prepping --- source/isaaclab/isaaclab/sim/simulation_context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index aa2d80eaa7c1..7519fd65a4b2 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -296,8 +296,8 @@ def _init_usd_physics_scene(self) -> None: UsdPhysics.SetStageKilogramsPerUnit(self.stage, 1.0) # Find and delete any existing physics scene. - # Collect paths first to avoid mutating the stage while traversing, - # which can invalidate the USD iterator. + # Collect paths first to avoid mutating the stage while traversing + # (iterator invalidation during deletion). physics_scene_paths = [ prim.GetPath().pathString for prim in self.stage.Traverse() if prim.GetTypeName() == "PhysicsScene" ] From 809748a83ea87e78e02f184bfd58bb984de6fdb6 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Thu, 16 Apr 2026 04:22:43 +0000 Subject: [PATCH 04/37] wip --- source/isaaclab/isaaclab/app/app_launcher.py | 183 +++++++++++++++--- .../isaaclab/sim/simulation_context.py | 95 ++++++--- .../isaaclab/visualizers/base_visualizer.py | 21 +- .../isaaclab/visualizers/visualizer_cfg.py | 22 +-- source/isaaclab/test/app/test_kwarg_launch.py | 23 ++- ...scene_data_provider_visualizer_contract.py | 39 +--- .../test_simulation_context_visualizers.py | 63 +++--- .../test/visualizers/test_visualizer.py | 14 +- source/isaaclab_newton/setup.py | 3 +- .../physx_scene_data_provider.py | 103 +--------- .../isaaclab_tasks/utils/sim_launcher.py | 28 +-- .../kit/kit_visualizer.py | 27 ++- .../newton/newton_visualizer.py | 28 +-- .../newton/newton_visualizer_cfg.py | 6 - .../isaaclab_visualizers/newton_adapter.py | 54 ++++++ .../rerun/rerun_visualizer.py | 24 +-- .../rerun/rerun_visualizer_cfg.py | 6 - .../viser/viser_visualizer.py | 26 +-- .../viser/viser_visualizer_cfg.py | 6 - source/isaaclab_visualizers/setup.py | 6 +- .../test/test_newton_adapter.py | 43 ++++ 21 files changed, 494 insertions(+), 326 deletions(-) create mode 100644 source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py create mode 100644 source/isaaclab_visualizers/test/test_newton_adapter.py diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index dcc8d1ca53e0..b7090341eb15 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -33,6 +33,112 @@ # import logger logger = logging.getLogger(__name__) + +def sync_visualizer_cli_settings_to_carb( + launcher_args: dict, + *, + cli_explicit: bool | None = None, + cli_disable_all: bool | None = None, +) -> None: + """Persist visualizer CLI flags (selection, env selection overrides) to carb settings. + + Optional Newton viewer arguments use :data:`argparse.SUPPRESS` defaults so only options the user + actually passed appear in *launcher_args*. We record ``cli_override/*`` booleans and only those + fields override :class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` in + :meth:`SimulationContext._apply_visualizer_cli_overrides`. + + Used by :class:`AppLauncher` and by standalone Newton/Rerun/Viser flows that skip Kit + (see :mod:`isaaclab_tasks.utils.sim_launcher`). + """ + visualizers = launcher_args.get("visualizer") + + if "viz_env_selection_max_visible" in launcher_args: + v = launcher_args["viz_env_selection_max_visible"] + if v is not None and int(v) < 0: + raise ValueError( + f"Invalid value for --viz_env_selection_max_visible: {v}. Expected non-negative int." + ) + + if "viz_env_selection_mode" in launcher_args: + mode_arg = launcher_args["viz_env_selection_mode"] + if mode_arg is not None and mode_arg not in ("none", "env_ids", "random_n"): + raise ValueError( + f"Invalid value for --viz_env_selection_mode: {mode_arg!r}. " + "Expected 'none', 'env_ids', or 'random_n'." + ) + + if cli_explicit is None: + cli_explicit = bool(launcher_args.get("visualizer_explicit", False)) + if cli_disable_all is None: + cli_disable_all = bool(cli_explicit) and visualizers is not None and "none" in visualizers + + with contextlib.suppress(Exception): + visualizer_str = " ".join(visualizers) if visualizers else "" + settings = get_settings_manager() + settings.set_string("/isaaclab/visualizer/types", visualizer_str) + settings.set_bool("/isaaclab/visualizer/explicit", cli_explicit) + settings.set_bool("/isaaclab/visualizer/disable_all", cli_disable_all) + + settings.set_bool( + "/isaaclab/visualizer/cli_override/viz_env_selection_max_visible", + "viz_env_selection_max_visible" in launcher_args, + ) + if "viz_env_selection_max_visible" in launcher_args: + settings.set_int( + "/isaaclab/visualizer/env_selection_max_visible", + int(launcher_args["viz_env_selection_max_visible"]), + ) + else: + settings.set_int("/isaaclab/visualizer/env_selection_max_visible", -1) + + settings.set_bool( + "/isaaclab/visualizer/cli_override/viz_env_selection_mode", + "viz_env_selection_mode" in launcher_args, + ) + if "viz_env_selection_mode" in launcher_args: + settings.set_string( + "/isaaclab/visualizer/env_selection_mode", str(launcher_args["viz_env_selection_mode"]) + ) + else: + settings.set_string("/isaaclab/visualizer/env_selection_mode", "") + + settings.set_bool( + "/isaaclab/visualizer/cli_override/viz_env_selection_ids", + "viz_env_selection_ids" in launcher_args, + ) + if "viz_env_selection_ids" in launcher_args: + settings.set_string( + "/isaaclab/visualizer/env_selection_ids", + str(launcher_args["viz_env_selection_ids"]).strip(), + ) + else: + settings.set_string("/isaaclab/visualizer/env_selection_ids", "") + + settings.set_bool( + "/isaaclab/visualizer/cli_override/viz_env_selection_random_count", + "viz_env_selection_random_count" in launcher_args, + ) + if "viz_env_selection_random_count" in launcher_args: + settings.set_int( + "/isaaclab/visualizer/env_selection_random_count", + int(launcher_args["viz_env_selection_random_count"]), + ) + else: + settings.set_int("/isaaclab/visualizer/env_selection_random_count", -1) + + settings.set_bool( + "/isaaclab/visualizer/cli_override/viz_env_selection_random_seed", + "viz_env_selection_random_seed" in launcher_args, + ) + if "viz_env_selection_random_seed" in launcher_args: + settings.set_int( + "/isaaclab/visualizer/env_selection_random_seed", + int(launcher_args["viz_env_selection_random_seed"]), + ) + else: + settings.set_int("/isaaclab/visualizer/env_selection_random_seed", -1) + + # Suppress noisy debug-level logs from third-party libraries logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("matplotlib").setLevel(logging.WARNING) @@ -188,7 +294,6 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa self._livestream: Literal[0, 1, 2] # 0: Disabled, 1: WebRTC public, 2: WebRTC private self._offscreen_render: bool # 0: Disabled, 1: Enabled self._sim_experience_file: str # Experience file to load - self._visualizer_max_worlds: int | None # Optional max worlds override for Newton-based visualizers self._video_enabled: bool # Whether --video recording is enabled # Exposed to train scripts @@ -330,10 +435,11 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: - Multiple visualizers can be specified as a comma-delimited list: ``--viz rerun,newton,viser``. - * ``visualizer_max_worlds`` (int | None): Optional global override for the maximum number of worlds - rendered in Newton-based visualizers (newton, rerun, viser). If omitted, each visualizer uses its - config default. + * ``viz_env_selection_max_visible`` (int | None): Optional global cap on how many envs each visualizer shows when + ``env_selection_mode`` is ``none`` (newton, rerun, viser, kit). If omitted, each visualizer uses its config default. + * ``viz_env_selection_mode`` / ``viz_env_selection_ids`` / ``viz_env_selection_random_count`` / ``viz_env_selection_random_seed``: + Optional global overrides for :class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` env selection. .. _`WebRTC`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client @@ -485,14 +591,41 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: ), ) arg_group.add_argument( - "--visualizer_max_worlds", + "--viz_env_selection_max_visible", type=int, - default=AppLauncher._APPLAUNCHER_CFG_INFO["visualizer_max_worlds"][1], + default=argparse.SUPPRESS, help=( - "Optional global max worlds override for Newton-based visualizers (newton/rerun/viser). " - "If omitted, visualizer config defaults are used." + "When set, overrides ``env_selection_max_visible`` on visualizer configs (newton/rerun/viser/kit) " + "when ``env_selection_mode`` is ``none``. If omitted, task/visualizer config values are kept." ), ) + arg_group.add_argument( + "--viz_env_selection_mode", + type=str, + default=argparse.SUPPRESS, + help=( + "When set, overrides ``env_selection_mode`` on visualizer configs " + "(none | env_ids | random_n). If omitted, task/visualizer config values are kept." + ), + ) + arg_group.add_argument( + "--viz_env_selection_ids", + type=str, + default=argparse.SUPPRESS, + help="When set, overrides ``env_selection_ids`` (comma-separated, e.g. 0,2,5).", + ) + arg_group.add_argument( + "--viz_env_selection_random_count", + type=int, + default=argparse.SUPPRESS, + help="When set, overrides ``env_selection_random_count``.", + ) + arg_group.add_argument( + "--viz_env_selection_random_seed", + type=int, + default=argparse.SUPPRESS, + help="When set, overrides ``env_selection_random_seed``.", + ) # special flag for backwards compatibility # Corresponding to the beginning of the function, @@ -513,7 +646,11 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "device": ([str], "cuda:0"), "experience": ([str], ""), "rendering_mode": ([str], "balanced"), - "visualizer_max_worlds": ([int, type(None)], None), + "viz_env_selection_max_visible": ([int, type(None)], None), + "viz_env_selection_mode": ([str, type(None)], None), + "viz_env_selection_ids": ([str, type(None)], None), + "viz_env_selection_random_count": ([int, type(None)], None), + "viz_env_selection_random_seed": ([int, type(None)], None), } """A dictionary of arguments added manually by the :meth:`AppLauncher.add_app_launcher_args` method. @@ -1152,28 +1289,12 @@ def _set_animation_recording_settings(self, launcher_args: dict) -> None: settings.set_float("/isaaclab/anim_recording/stop_time", stop_time) def _set_visualizer_settings(self, launcher_args: dict) -> None: - """Store visualizer selection and max-worlds override in settings.""" - visualizers = launcher_args.get("visualizer") - visualizer_max_worlds = launcher_args.get("visualizer_max_worlds") - - if visualizer_max_worlds is not None and visualizer_max_worlds < 0: - raise ValueError( - f"Invalid value for --visualizer_max_worlds: {visualizer_max_worlds}. Expected non-negative int." - ) - - with contextlib.suppress(Exception): - visualizer_str = " ".join(visualizers) if visualizers else "" - settings = get_settings_manager() - cli_visualizer_explicit = getattr(self, "_cli_visualizer_explicit", False) - cli_visualizer_disable_all = getattr(self, "_cli_visualizer_disable_all", False) - settings.set_string("/isaaclab/visualizer/types", visualizer_str) - settings.set_bool("/isaaclab/visualizer/explicit", cli_visualizer_explicit) - settings.set_bool("/isaaclab/visualizer/disable_all", cli_visualizer_disable_all) - # Store as int setting where -1 means "use per-visualizer defaults". - if visualizer_max_worlds is None: - settings.set_int("/isaaclab/visualizer/max_worlds", -1) - else: - settings.set_int("/isaaclab/visualizer/max_worlds", int(visualizer_max_worlds)) + """Store visualizer selection and Newton viewer CLI overrides in settings.""" + sync_visualizer_cli_settings_to_carb( + launcher_args, + cli_explicit=getattr(self, "_cli_visualizer_explicit", False), + cli_disable_all=getattr(self, "_cli_visualizer_disable_all", False), + ) def _interrupt_signal_handle_callback(self, signal, frame): """Handle the interrupt signal from the keyboard.""" diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 7519fd65a4b2..8835e0cc7220 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -430,39 +430,90 @@ def _get_cli_visualizer_types(self) -> list[str]: # App launcher writes this as a single string; accept comma and/or whitespace separators. return [value for chunk in requested.split(",") for value in chunk.split() if value] - def _get_cli_visualizer_max_worlds_override(self) -> tuple[bool, int | None]: - """Return CLI override for visualizer max worlds. + def _cli_visualizer_field_overridden(self, field: str) -> bool: + """Return True when the user passed the matching CLI flag (see ``cli_override/*`` settings).""" + v = self.get_setting(f"/isaaclab/visualizer/cli_override/{field}") + if v is not None: + return bool(v) + # Legacy: before cli_override existed, a non-negative env_selection_max_visible int implied CLI intent. + if field == "viz_env_selection_max_visible": + raw = self.get_setting("/isaaclab/visualizer/env_selection_max_visible") + if raw is None: + return False + try: + return int(raw) >= 0 + except (TypeError, ValueError): + return False + return False - Returns: - Tuple of (has_override, value), where value=None means no override. - """ - value = self.get_setting("/isaaclab/visualizer/max_worlds") + def _get_cli_visualizer_env_selection_max_visible_override(self) -> tuple[bool, int | None]: + """Return CLI override for ``env_selection_max_visible`` when the user passed ``--viz_env_selection_max_visible``.""" + if not self._cli_visualizer_field_overridden("viz_env_selection_max_visible"): + return False, None + value = self.get_setting("/isaaclab/visualizer/env_selection_max_visible") if value is None: return False, None try: - max_worlds = int(value) + max_visible = int(value) except (TypeError, ValueError): - logger.warning("[SimulationContext] Invalid /isaaclab/visualizer/max_worlds setting: %r", value) + logger.warning( + "[SimulationContext] Invalid /isaaclab/visualizer/env_selection_max_visible setting: %r", value + ) return False, None - - # -1 means no CLI override. - if max_worlds < 0: + if max_visible < 0: return False, None - return True, max_worlds + return True, max_visible - def _apply_visualizer_cli_overrides(self, visualizer_cfgs: list[Any]) -> None: - """Apply CLI visualizer overrides (e.g., max worlds) to resolved configs. + def _parse_cli_env_selection_ids_setting(self) -> list[int]: + ids_raw = self.get_setting("/isaaclab/visualizer/env_selection_ids") + if ids_raw is None or not str(ids_raw).strip(): + return [] + parts = [p.strip() for p in str(ids_raw).split(",") if p.strip()] + parsed: list[int] = [] + for p in parts: + try: + parsed.append(int(p)) + except ValueError: + logger.warning( + "[SimulationContext] Invalid env id in /isaaclab/visualizer/env_selection_ids: %r", p + ) + return parsed - Args: - visualizer_cfgs: Resolved visualizer configs to update in-place. - """ - has_max_worlds_override, max_worlds_override = self._get_cli_visualizer_max_worlds_override() - if not has_max_worlds_override: - return + def _apply_visualizer_cli_overrides(self, visualizer_cfgs: list[Any]) -> None: + """Apply CLI visualizer overrides to resolved configs (only fields the user set on the CLI).""" + has_max, max_visible_override = self._get_cli_visualizer_env_selection_max_visible_override() + if has_max: + for cfg in visualizer_cfgs: + if hasattr(cfg, "env_selection_max_visible"): + cfg.env_selection_max_visible = max_visible_override for cfg in visualizer_cfgs: - if hasattr(cfg, "max_worlds"): - cfg.max_worlds = max_worlds_override + if not hasattr(cfg, "env_selection_mode"): + continue + if self._cli_visualizer_field_overridden("viz_env_selection_mode"): + mode = self.get_setting("/isaaclab/visualizer/env_selection_mode") + if mode is not None and str(mode).strip(): + cfg.env_selection_mode = str(mode).strip() + if self._cli_visualizer_field_overridden("viz_env_selection_ids"): + cfg.env_selection_ids = list(self._parse_cli_env_selection_ids_setting()) + if self._cli_visualizer_field_overridden("viz_env_selection_random_count"): + rn = self.get_setting("/isaaclab/visualizer/env_selection_random_count") + if rn is not None: + try: + cfg.env_selection_random_count = int(rn) + except (TypeError, ValueError): + logger.warning( + "[SimulationContext] Invalid /isaaclab/visualizer/env_selection_random_count: %r", rn + ) + if self._cli_visualizer_field_overridden("viz_env_selection_random_seed"): + seed = self.get_setting("/isaaclab/visualizer/env_selection_random_seed") + if seed is not None: + try: + cfg.env_selection_random_seed = int(seed) + except (TypeError, ValueError): + logger.warning( + "[SimulationContext] Invalid /isaaclab/visualizer/env_selection_random_seed: %r", seed + ) def _is_cli_visualizer_explicit(self) -> bool: """Return ``True`` when visualizers were explicitly provided via CLI.""" diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index e8a896ad5628..15049371448d 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -146,28 +146,27 @@ def _compute_visualized_env_ids(self) -> list[int] | None: """ if self._scene_data_provider is None: return None - filter_mode = getattr(self.cfg, "env_filter_mode", "none") - if filter_mode == "none": + cfg = self.cfg + if cfg.env_selection_mode == "none": return None num_envs = self._scene_data_provider.get_metadata().get("num_envs", 0) if num_envs <= 0: - logger.debug("[Visualizer] num_envs is 0 or missing from provider metadata; env filtering disabled.") + logger.debug("[Visualizer] num_envs is 0 or missing from provider metadata; env selection disabled.") return None - if filter_mode == "env_ids": - env_ids_cfg = getattr(self.cfg, "env_filter_ids", None) - if env_ids_cfg is not None and len(env_ids_cfg) > 0: - return [i for i in env_ids_cfg if 0 <= i < num_envs] + if cfg.env_selection_mode == "env_ids": + if len(cfg.env_selection_ids) > 0: + return [i for i in cfg.env_selection_ids if 0 <= i < num_envs] return None - if filter_mode == "random_n": - count = int(getattr(self.cfg, "env_filter_random_n", 0)) + if cfg.env_selection_mode == "random_n": + count = int(cfg.env_selection_random_count) if count <= 0: return None count = min(count, num_envs) - seed = int(getattr(self.cfg, "env_filter_seed", 0)) + seed = int(cfg.env_selection_random_seed) rng = random.Random(seed) return sorted(rng.sample(range(num_envs), count)) - logger.warning("[Visualizer] Unknown env_filter_mode='%s'; defaulting to all envs.", filter_mode) + logger.warning("[Visualizer] Unknown env_selection_mode='%s'; defaulting to all envs.", cfg.env_selection_mode) return None def get_rendering_dt(self) -> float | None: diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 3f62c3e5232e..350aacb4d3dc 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -46,21 +46,21 @@ class VisualizerCfg: cam_prim_path: str = "/World/envs/env_0/Camera" """Absolute USD path to a camera prim when cam_source='prim_path'.""" - env_filter_mode: Literal["none", "env_ids", "random_n"] = "none" - """Env filter mode: 'none', 'env_ids', or 'random_n'.""" + env_selection_max_visible: int | None = 4 + """When ``env_selection_mode`` is ``none``, optional cap on how many envs are shown (``0..num_envs-1``).""" - env_filter_random_n: int = 64 - """If env_filter_mode='random_n', number of envs to sample.""" + env_selection_mode: Literal["none", "env_ids", "random_n"] = "none" + """How env indices are chosen for viewers: ``none`` (use :attr:`env_selection_max_visible` only), ``env_ids``, or ``random_n``.""" - env_filter_seed: int = 0 - """Seed for deterministic env sampling.""" + env_selection_ids: list[int] = [i for i in range(0, 64, 4)] + """When ``env_selection_mode`` is ``env_ids``, only these env indices are shown. + """ - env_filter_ids: list[int] = [i for i in range(0, 64, 4)] - """If env_filter_mode='env_ids', only these env indices are shown. + env_selection_random_count: int = 64 + """When ``env_selection_mode`` is ``random_n``, number of env indices to sample.""" - This improves performance, particularly for large-scale training, by reducing scene updates sent to visualizers. - Note, OV visualizer only applies a cosmetic visibility toggle (no performance gain). - """ + env_selection_random_seed: int = 0 + """Seed for deterministic sampling when ``env_selection_mode`` is ``random_n``.""" def get_visualizer_type(self) -> str | None: """Get the visualizer type identifier. diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index 0dffa89764a0..c4600299ca1f 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -43,25 +43,36 @@ def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) launcher = AppLauncher.__new__(AppLauncher) - launcher._set_visualizer_settings({"visualizer": ["viser", "rerun"], "visualizer_max_worlds": 0}) + launcher._set_visualizer_settings({"visualizer": ["viser", "rerun"], "viz_env_selection_max_visible": 0}) assert settings.values == { "/isaaclab/visualizer/types": "viser rerun", "/isaaclab/visualizer/explicit": False, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_worlds": 0, + "/isaaclab/visualizer/cli_override/viz_env_selection_max_visible": True, + "/isaaclab/visualizer/env_selection_max_visible": 0, + "/isaaclab/visualizer/cli_override/viz_env_selection_mode": False, + "/isaaclab/visualizer/env_selection_mode": "", + "/isaaclab/visualizer/cli_override/viz_env_selection_ids": False, + "/isaaclab/visualizer/env_selection_ids": "", + "/isaaclab/visualizer/cli_override/viz_env_selection_random_count": False, + "/isaaclab/visualizer/env_selection_random_count": -1, + "/isaaclab/visualizer/cli_override/viz_env_selection_random_seed": False, + "/isaaclab/visualizer/env_selection_random_seed": -1, } -def test_set_visualizer_settings_rejects_negative_max_worlds(monkeypatch: pytest.MonkeyPatch): +def test_set_visualizer_settings_rejects_negative_viz_env_selection_max_visible( + monkeypatch: pytest.MonkeyPatch, +): def _unexpected_settings_manager(): raise AssertionError("settings manager should not be queried for invalid values") monkeypatch.setattr(app_launcher_module, "get_settings_manager", _unexpected_settings_manager) launcher = AppLauncher.__new__(AppLauncher) - with pytest.raises(ValueError, match="Invalid value for --visualizer_max_worlds: -5"): - launcher._set_visualizer_settings({"visualizer": ["viser"], "visualizer_max_worlds": -5}) + with pytest.raises(ValueError, match="Invalid value for --viz_env_selection_max_visible: -5"): + launcher._set_visualizer_settings({"visualizer": ["viser"], "viz_env_selection_max_visible": -5}) def test_set_visualizer_settings_suppresses_settings_manager_errors(monkeypatch: pytest.MonkeyPatch): @@ -71,7 +82,7 @@ def _raise_settings_error(): monkeypatch.setattr(app_launcher_module, "get_settings_manager", _raise_settings_error) launcher = AppLauncher.__new__(AppLauncher) - launcher._set_visualizer_settings({"visualizer": ["viser"], "visualizer_max_worlds": 3}) + launcher._set_visualizer_settings({"visualizer": ["viser"], "viz_env_selection_max_visible": 3}) def test_parse_visualizer_csv_accepts_comma_delimited_values(): diff --git a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py b/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py index abbc3046655a..8313069b996d 100644 --- a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py +++ b/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py @@ -20,38 +20,14 @@ def _make_provider(): return provider -def test_get_newton_model_for_env_ids_builds_and_caches_sorted_keys(): +def test_get_newton_model_for_env_ids_returns_full_model(): + """Filtered partial USD models were removed; callers always receive the full Newton model.""" provider = _make_provider() provider._needs_newton_sync = True provider._newton_model = "full-model" - provider._filtered_newton_model = None - provider._filtered_env_ids_key = None - build_calls = [] - - def _fake_build(env_ids): - build_calls.append(env_ids) - provider._filtered_newton_model = f"filtered-{env_ids}" - - provider._build_filtered_newton_model = _fake_build - - # None asks for the full model. assert provider.get_newton_model_for_env_ids(None) == "full-model" - - # First subset request builds using sorted env id key. - model_a = provider.get_newton_model_for_env_ids([3, 1]) - assert model_a == "filtered-[1, 3]" - assert build_calls == [[1, 3]] - - # Equivalent request should use cache and not rebuild. - model_b = provider.get_newton_model_for_env_ids([1, 3]) - assert model_b == "filtered-[1, 3]" - assert build_calls == [[1, 3]] - - # Different subset rebuilds. - model_c = provider.get_newton_model_for_env_ids([2]) - assert model_c == "filtered-[2]" - assert build_calls == [[1, 3], [2]] + assert provider.get_newton_model_for_env_ids([3, 1]) == "full-model" def test_try_use_prebuilt_artifact_populates_provider_state(): @@ -75,11 +51,6 @@ def test_try_use_prebuilt_artifact_populates_provider_state(): provider._covered_buf = object() provider._xform_mask_buf = object() provider._env_id_to_body_indices = {0: [0]} - provider._filtered_newton_model = "old-filtered-model" - provider._filtered_newton_state = "old-filtered-state" - provider._filtered_env_ids_key = (0,) - provider._filtered_body_indices = [0] - provider._stage = None assert provider._try_use_prebuilt_newton_artifact() is True assert provider._newton_model == "prebuilt-model" @@ -96,10 +67,6 @@ def test_try_use_prebuilt_artifact_populates_provider_state(): assert provider._covered_buf is None assert provider._xform_mask_buf is None assert provider._env_id_to_body_indices == {} - assert provider._filtered_newton_model is None - assert provider._filtered_newton_state is None - assert provider._filtered_env_ids_key is None - assert provider._filtered_body_indices == [] def test_try_use_prebuilt_artifact_respects_force_usd_fallback_flag(): diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 07d9ab0cb4c4..31a23b8db1f8 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -224,15 +224,17 @@ def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None @pytest.mark.parametrize( - ("cfg_max_worlds", "expected_max_worlds"), + ("cfg_env_selection_max_visible", "expected_visible"), [ (None, None), - (0, 0), - (3, 3), + (0, []), + (3, [0, 1, 2]), ], ) -def test_viser_visualizer_create_viewer_forwards_max_worlds( - monkeypatch: pytest.MonkeyPatch, cfg_max_worlds: int | None, expected_max_worlds: int | None +def test_viser_visualizer_create_viewer_applies_visible_worlds( + monkeypatch: pytest.MonkeyPatch, + cfg_env_selection_max_visible: int | None, + expected_visible: list[int] | None, ): captured = {} @@ -256,8 +258,11 @@ def __init__( "metadata": metadata, } - def set_model(self, model: Any, max_worlds: int | None) -> None: - captured["set_model"] = {"model": model, "max_worlds": max_worlds} + def set_model(self, model: Any) -> None: + captured["set_model"] = model + + def set_visible_worlds(self, worlds) -> None: + captured["visible_worlds"] = worlds def set_world_offsets(self, spacing) -> None: captured["set_world_offsets"] = tuple(spacing) @@ -270,25 +275,29 @@ def set_world_offsets(self, spacing) -> None: ) monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_set_viser_camera_view", lambda self, pose: None) - cfg = ViserVisualizerCfg(max_worlds=cfg_max_worlds, open_browser=False) + cfg = ViserVisualizerCfg(env_selection_max_visible=cfg_env_selection_max_visible, open_browser=False) visualizer = viser_visualizer.ViserVisualizer(cfg) visualizer._model = "dummy-model" + visualizer._env_ids = None # normally set by initialize() -> _compute_visualized_env_ids() visualizer._create_viewer(record_to_viser="record.viser", metadata={"num_envs": 8}) - assert captured["set_model"] == {"model": "dummy-model", "max_worlds": expected_max_worlds} + assert captured["set_model"] == "dummy-model" + assert captured["visible_worlds"] == expected_visible assert captured["set_world_offsets"] == (0.0, 0.0, 0.0) @pytest.mark.parametrize( - ("cfg_max_worlds", "expected_max_worlds"), + ("cfg_env_selection_max_visible", "expected_visible"), [ (None, None), - (0, 0), - (3, 3), + (0, []), + (3, [0, 1, 2]), ], ) -def test_rerun_visualizer_initialize_forwards_max_worlds_and_world_offsets( - monkeypatch: pytest.MonkeyPatch, cfg_max_worlds: int | None, expected_max_worlds: int | None +def test_rerun_visualizer_initialize_applies_visible_worlds_and_world_offsets( + monkeypatch: pytest.MonkeyPatch, + cfg_env_selection_max_visible: int | None, + expected_visible: list[int] | None, ): captured = {} @@ -316,8 +325,11 @@ def __init__( "record_to_rrd": record_to_rrd, } - def set_model(self, model: Any, max_worlds: int | None = None) -> None: - captured["set_model"] = {"model": model, "max_worlds": max_worlds} + def set_model(self, model: Any) -> None: + captured["set_model"] = model + + def set_visible_worlds(self, worlds) -> None: + captured["visible_worlds"] = worlds def set_world_offsets(self, spacing) -> None: captured["set_world_offsets"] = tuple(spacing) @@ -347,11 +359,12 @@ def get_newton_state(self, env_ids: list[int] | None): ) monkeypatch.setattr(rerun_visualizer.RerunVisualizer, "_apply_camera_pose", lambda self, pose: None) - cfg = RerunVisualizerCfg(open_browser=False, max_worlds=cfg_max_worlds) + cfg = RerunVisualizerCfg(open_browser=False, env_selection_max_visible=cfg_env_selection_max_visible) visualizer = rerun_visualizer.RerunVisualizer(cfg) visualizer.initialize(cast(Any, _DummyRerunSceneDataProvider())) - assert captured["set_model"] == {"model": "dummy-model", "max_worlds": expected_max_worlds} + assert captured["set_model"] == "dummy-model" + assert captured["visible_worlds"] == expected_visible assert captured["set_world_offsets"] == (0.0, 0.0, 0.0) @@ -456,7 +469,7 @@ def test_explicit_unknown_visualizer_type_raises(): "/isaaclab/visualizer/types": "bogus_viz", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_worlds": None, + "/isaaclab/visualizer/env_selection_max_visible": None, } ctx = _make_context_with_settings(settings) @@ -470,7 +483,7 @@ def test_explicit_missing_package_raises(monkeypatch: pytest.MonkeyPatch): "/isaaclab/visualizer/types": "rerun", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_worlds": None, + "/isaaclab/visualizer/env_selection_max_visible": None, } ctx = _make_context_with_settings(settings) @@ -497,7 +510,7 @@ def test_explicit_visualizer_create_failure_raises(monkeypatch: pytest.MonkeyPat "/isaaclab/visualizer/types": "newton", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_worlds": None, + "/isaaclab/visualizer/env_selection_max_visible": None, } ctx = _make_context_with_settings(settings, visualizer_cfgs=[failing_cfg]) @@ -516,7 +529,7 @@ def test_explicit_visualizer_init_failure_raises(monkeypatch: pytest.MonkeyPatch "/isaaclab/visualizer/types": "newton", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_worlds": None, + "/isaaclab/visualizer/env_selection_max_visible": None, } ctx = _make_context_with_settings(settings, visualizer_cfgs=[failing_cfg]) @@ -534,7 +547,7 @@ def test_explicit_partial_valid_types_raises_for_invalid(): "/isaaclab/visualizer/types": "newton,bogus_viz", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_worlds": None, + "/isaaclab/visualizer/env_selection_max_visible": None, } ctx = _make_context_with_settings(settings) @@ -548,7 +561,7 @@ def test_non_explicit_unknown_type_silently_skipped(caplog): "/isaaclab/visualizer/types": "bogus_viz", "/isaaclab/visualizer/explicit": False, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_worlds": None, + "/isaaclab/visualizer/env_selection_max_visible": None, } ctx = _make_context_with_settings(settings) @@ -564,7 +577,7 @@ def test_non_explicit_create_failure_silently_logged(monkeypatch: pytest.MonkeyP "/isaaclab/visualizer/types": "", "/isaaclab/visualizer/explicit": False, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/max_worlds": None, + "/isaaclab/visualizer/env_selection_max_visible": None, } ctx = _make_context_with_settings(settings, visualizer_cfgs=[failing_cfg]) diff --git a/source/isaaclab/test/visualizers/test_visualizer.py b/source/isaaclab/test/visualizers/test_visualizer.py index ed1baf9198c1..65cef261282f 100644 --- a/source/isaaclab/test/visualizers/test_visualizer.py +++ b/source/isaaclab/test/visualizers/test_visualizer.py @@ -61,10 +61,10 @@ def is_running(self) -> bool: def _make_cfg(**kwargs): cfg = { - "env_filter_mode": "none", - "env_filter_ids": [0, 2, 4], - "env_filter_random_n": 2, - "env_filter_seed": 7, + "env_selection_mode": "none", + "env_selection_ids": [0, 2, 4], + "env_selection_random_count": 2, + "env_selection_random_seed": 7, } cfg.update(kwargs) return SimpleNamespace(**cfg) @@ -83,19 +83,19 @@ def get_camera_transforms(self): def test_compute_visualized_env_ids_none_mode(): - viz = _DummyVisualizer(_make_cfg(env_filter_mode="none")) + viz = _DummyVisualizer(_make_cfg(env_selection_mode="none")) viz._scene_data_provider = _FakeProvider(num_envs=8) assert viz._compute_visualized_env_ids() is None def test_compute_visualized_env_ids_from_ids_filters_out_of_range(): - viz = _DummyVisualizer(_make_cfg(env_filter_mode="env_ids", env_filter_ids=[-1, 0, 3, 99])) + viz = _DummyVisualizer(_make_cfg(env_selection_mode="env_ids", env_selection_ids=[-1, 0, 3, 99])) viz._scene_data_provider = _FakeProvider(num_envs=4) assert viz._compute_visualized_env_ids() == [0, 3] def test_compute_visualized_env_ids_random_n_is_deterministic(): - cfg = _make_cfg(env_filter_mode="random_n", env_filter_random_n=3, env_filter_seed=123) + cfg = _make_cfg(env_selection_mode="random_n", env_selection_random_count=3, env_selection_random_seed=123) viz_a = _DummyVisualizer(cfg) viz_b = _DummyVisualizer(cfg) viz_a._scene_data_provider = _FakeProvider(num_envs=10) diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index 421cecd502ca..256cdb601e32 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -45,7 +45,8 @@ def run(self): "mujoco==3.5.0", "mujoco-warp==3.5.0.2", "PyOpenGL-accelerate==3.1.10", - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + # Includes PR #2267: ViewerBase.set_visible_worlds() for Rerun/Viser/GL world filtering. + "newton @ git+https://github.com/newton-physics/newton.git@7e036f542437046f2dc14028e189cb8428afd191", ], } diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py index 9403cd40aa46..2f83fde884a4 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py @@ -136,10 +136,6 @@ def __init__(self, stage, simulation_context) -> None: self._device = getattr(self._simulation_context, "device", "cuda:0") self._newton_model = None self._newton_state = None - self._filtered_newton_model = None - self._filtered_newton_state = None - self._filtered_env_ids_key: tuple[int, ...] | None = None - self._filtered_body_indices: list[int] = [] self._rigid_body_paths: list[str] = [] # Paths used to create PhysX views. May include articulation roots for coverage. self._rigid_body_view_paths: list[str] = [] @@ -247,10 +243,6 @@ def _try_use_prebuilt_newton_artifact(self) -> bool: self._xform_mask_buf = None self._env_id_to_body_indices = {} self._num_envs_at_last_newton_build = int(artifact.num_envs) - self._filtered_newton_model = None - self._filtered_newton_state = None - self._filtered_env_ids_key = None - self._filtered_body_indices = [] return True def _build_newton_model_from_usd(self) -> None: @@ -293,11 +285,6 @@ def _build_newton_model_from_usd(self) -> None: self._xform_mask_buf = None self._env_id_to_body_indices = {} self._num_envs_at_last_newton_build = self.get_num_envs() - # Invalidate any filtered model when full model changes. - self._filtered_newton_model = None - self._filtered_newton_state = None - self._filtered_env_ids_key = None - self._filtered_body_indices = [] except ModuleNotFoundError as exc: self._last_newton_model_build_source = "error" logger.error( @@ -327,61 +314,6 @@ def _build_newton_model_from_usd(self) -> None: elapsed_ms, ) - def _build_filtered_newton_model(self, env_ids: list[int]) -> None: - """Build Newton model/state for a subset of environments. - - Args: - env_ids: Environment ids to include in the subset model. - """ - # TODO: Deprecate this USD-traversal fallback once cloner/prebuilt coverage - # is complete for full and partial visualization model-build paths. - try: - from newton import ModelBuilder - - # Newton model building from USD with partial visualization does not currently use cloner, - # and falls back to slower USD-stage traversal. - builder = ModelBuilder(up_axis=self._up_axis) - builder.add_usd(self._stage, ignore_paths=[r"/World/envs/.*"]) - for env_id in env_ids: - builder.begin_world() - builder.add_usd(self._stage, root_path=f"/World/envs/env_{env_id}") - builder.end_world() - - self._filtered_newton_model = builder.finalize(device=self._device) - self._filtered_newton_state = self._filtered_newton_model.state() - - replace_newton_shape_colors(self._filtered_newton_model, self._stage) - - full_index_by_path = {path: i for i, path in enumerate(self._rigid_body_paths)} - filtered_paths = self._model_body_paths(self._filtered_newton_model) - self._filtered_body_indices = [] - missing = [] - for path in filtered_paths: - idx = full_index_by_path.get(path) - if idx is None: - missing.append(path) - else: - self._filtered_body_indices.append(idx) - if missing: - logger.warning( - "[PhysxSceneDataProvider] Filtered model contains %d bodies not in full model.", - len(missing), - ) - except ModuleNotFoundError as exc: - logger.error( - "[PhysxSceneDataProvider] Newton module not available. " - "Install the Newton backend to use newton/rerun/viser visualizers." - ) - logger.debug(f"[PhysxSceneDataProvider] Newton import error: {exc}") - self._filtered_newton_model = None - self._filtered_newton_state = None - self._filtered_body_indices = [] - except Exception as exc: - logger.error(f"[PhysxSceneDataProvider] Failed to build filtered Newton model from USD: {exc}") - self._filtered_newton_model = None - self._filtered_newton_state = None - self._filtered_body_indices = [] - def _build_env_id_to_body_indices(self) -> None: """Build mapping env_id -> list of body indices from rigid_body_paths.""" self._env_id_to_body_indices = {} @@ -752,30 +684,20 @@ def get_newton_model(self) -> Any | None: return self._newton_model if self._needs_newton_sync else None def get_newton_model_for_env_ids(self, env_ids: list[int] | None) -> Any | None: - """Return Newton model for selected environments. + """Return the full Newton model (``env_ids`` is ignored). - Args: - env_ids: Optional environment ids. ``None`` returns full model. - - Returns: - Full or filtered Newton model, or ``None`` when unavailable. + Newton viewers select visible worlds via ``ViewerBase.set_visible_worlds`` using the full + model and full state; partial USD builds are no longer used. """ - if not self._needs_newton_sync: - return None - if env_ids is None: - return self._newton_model - env_ids_key = tuple(sorted(env_ids)) - if self._filtered_newton_model is None or self._filtered_env_ids_key != env_ids_key: - self._filtered_env_ids_key = env_ids_key - self._build_filtered_newton_model(list(env_ids_key)) - return self._filtered_newton_model + del env_ids + return self.get_newton_model() def get_newton_state(self, env_ids: list[int] | None = None) -> Any | None: """Return Newton state when sync is enabled. If env_ids is None, returns the full state. If env_ids is provided, returns a state-like object whose body_q contains only the bodies for those envs (same order - as in the full model, for use with e.g. max_worlds=len(env_ids)). + as in the full model). """ if not self._needs_newton_sync or self._newton_state is None: return None @@ -783,19 +705,6 @@ def get_newton_state(self, env_ids: list[int] | None = None) -> Any | None: return self._newton_state if not self._env_id_to_body_indices: return self._create_empty_subset_state() - env_ids_key = tuple(sorted(env_ids)) - if self._filtered_newton_model is not None and self._filtered_env_ids_key == env_ids_key: - if not self._filtered_body_indices: - return self._create_empty_subset_state() - try: - import warp as wp - - body_q_t = wp.to_torch(self._newton_state.body_q) - subset = body_q_t[self._filtered_body_indices].clone() - self._filtered_newton_state.body_q = wp.from_torch(subset, dtype=wp.transformf) - return self._filtered_newton_state - except Exception: - return self._newton_state body_indices = [] for eid in env_ids: body_indices.extend(self._env_id_to_body_indices.get(eid, [])) diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index 964027858dae..0fd5c3d21912 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -211,18 +211,22 @@ def launch_simulation( app_launcher = AppLauncher(launcher_args) close_fn = app_launcher.app.close elif visualizer_types: - # Newton path without Kit: AppLauncher is skipped, so manually store the visualizer - # selection in SettingsManager (works in standalone mode via plain dict) so that - # SimulationContext._get_cli_visualizer_types() can find it. - from isaaclab.app.settings_manager import get_settings_manager - - disable_all = "none" in visualizer_types - active_types = [] if disable_all else sorted(visualizer_types) - visualizer_str = " ".join(active_types) - settings = get_settings_manager() - settings.set_string("/isaaclab/visualizer/types", visualizer_str) - settings.set_bool("/isaaclab/visualizer/explicit", True) - settings.set_bool("/isaaclab/visualizer/disable_all", disable_all) + # Newton path without Kit: AppLauncher is skipped — persist the same visualizer CLI + # settings (types, env_selection_*, viz_env_selection_*) that AppLauncher would write. + from isaaclab.app.app_launcher import sync_visualizer_cli_settings_to_carb + + if isinstance(launcher_args, argparse.Namespace): + sync_visualizer_cli_settings_to_carb( + vars(launcher_args), + cli_explicit=True, + cli_disable_all=("none" in visualizer_types), + ) + elif isinstance(launcher_args, dict): + sync_visualizer_cli_settings_to_carb( + launcher_args, + cli_explicit=True, + cli_disable_all=("none" in visualizer_types), + ) try: yield diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 3ad3ffd01326..f74d7edc3c62 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -16,6 +16,8 @@ from isaaclab.app.settings_manager import get_settings_manager from isaaclab.visualizers.base_visualizer import BaseVisualizer +from isaaclab_visualizers.newton_adapter import resolve_visible_env_indices + from .kit_visualizer_cfg import KitVisualizerCfg logger = logging.getLogger(__name__) @@ -73,12 +75,20 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._setup_viewport() self._env_ids = self._compute_visualized_env_ids() - if self._env_ids: + num_envs_meta = int(metadata.get("num_envs", 0)) + self._resolved_visible_env_ids = resolve_visible_env_indices( + self._env_ids, self.cfg.env_selection_max_visible, num_envs_meta + ) + if self._resolved_visible_env_ids is not None: logger.warning( - "[KitVisualizer] env_filter_ids filtering is cosmetic only (no perf gain) in OV; hiding other envs." + "[KitVisualizer] Partial visualization is cosmetic only in OV (no perf guarantee); hiding other envs." ) - self._apply_env_visibility(usd_stage, metadata) - num_visualized_envs = len(self._env_ids) if self._env_ids is not None else int(metadata.get("num_envs", 0)) + self._apply_env_visibility(usd_stage, metadata, self._resolved_visible_env_ids) + num_visualized_envs = ( + len(self._resolved_visible_env_ids) + if self._resolved_visible_env_ids is not None + else num_envs_meta + ) self._log_initialization_table( logger=logger, title="KitVisualizer Configuration", @@ -86,6 +96,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: ("eye", self.cfg.eye), ("lookat", self.cfg.lookat), ("cam_source", self.cfg.cam_source), + ("env_selection_max_visible", self.cfg.env_selection_max_visible), ("num_visualized_envs", num_visualized_envs), ("create_viewport", self.cfg.create_viewport), ("headless", self._runtime_headless), @@ -352,14 +363,12 @@ def _set_active_camera_path(self, camera_path: str) -> bool: self._viewport_api.set_active_camera(camera_path) return True - def _apply_env_visibility(self, usd_stage, metadata: dict) -> None: - """Hide non-selected environments for cosmetic env filtering.""" - if not self._env_ids: - return + def _apply_env_visibility(self, usd_stage, metadata: dict, visible_env_ids: list[int]) -> None: + """Hide environments not listed in ``visible_env_ids`` (cosmetic partial visualization).""" num_envs = int(metadata.get("num_envs", 0)) if num_envs <= 0: return - visible = set(self._env_ids) + visible = set(visible_env_ids) for env_id in range(num_envs): if env_id in visible: continue diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 72172b1ecf7d..3080ebe691e4 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -16,6 +16,8 @@ from isaaclab.visualizers.base_visualizer import BaseVisualizer +from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds + from .newton_visualizer_cfg import NewtonVisualizerCfg logger = logging.getLogger(__name__) @@ -281,16 +283,11 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._scene_data_provider = scene_data_provider metadata = scene_data_provider.get_metadata() + num_envs = int(metadata.get("num_envs", 0)) self._env_ids = self._compute_visualized_env_ids() - if self._env_ids: - get_filtered_model = getattr(scene_data_provider, "get_newton_model_for_env_ids", None) - if callable(get_filtered_model): - self._model = get_filtered_model(self._env_ids) - else: - self._model = scene_data_provider.get_newton_model() - else: - self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state(self._env_ids) + # Full model + ViewerBase.set_visible_worlds() (Newton PR #2267); avoids cloning a reduced model. + self._model = scene_data_provider.get_newton_model() + self._state = scene_data_provider.get_newton_state(None) # Use pyglet's EGL headless backend when requested. Must run before the first # ``pyglet.window`` import so ``Window`` resolves to :class:`~pyglet.window.headless.HeadlessWindow`. @@ -308,8 +305,13 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: ) if self._viewer is not None: - max_worlds = self.cfg.max_worlds - self._viewer.set_model(self._model, max_worlds=max_worlds) + self._viewer.set_model(self._model) + apply_viewer_visible_worlds( + self._viewer, + env_ids=self._env_ids, + env_selection_max_visible=self.cfg.env_selection_max_visible, + num_envs=num_envs, + ) self._viewer.set_world_offsets((0.0, 0.0, 0.0)) initial_pose = self._resolve_initial_camera_pose() self._apply_camera_pose(initial_pose) @@ -365,13 +367,13 @@ def step(self, dt: float) -> None: if self._viewer is None: if self._scene_data_provider is not None: - self._state = self._scene_data_provider.get_newton_state(self._env_ids) + self._state = self._scene_data_provider.get_newton_state(None) return if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() - self._state = self._scene_data_provider.get_newton_state(self._env_ids) + self._state = self._scene_data_provider.get_newton_state(None) contacts = None if self._viewer.show_contacts: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py index b89e0a2d547c..711e86e03b31 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer_cfg.py @@ -25,12 +25,6 @@ class NewtonVisualizerCfg(VisualizerCfg): headless: bool = False """Run the Newton viewer without requiring a display server.""" - max_worlds: int | None = None - """Maximum number of worlds/environments rendered by the viewer. - - Set to ``None`` to leave this option disabled. - """ - update_frequency: int = 1 """Visualizer update frequency (updates every N frames).""" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py new file mode 100644 index 000000000000..a8f7a8a40514 --- /dev/null +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py @@ -0,0 +1,54 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Shared helpers for viewer env selection (Newton viewers and Kit partial USD visibility).""" + +from __future__ import annotations + + +def resolve_visible_env_indices( + env_ids: list[int] | None, + env_selection_max_visible: int | None, + num_envs: int, +) -> list[int] | None: + """Resolve which env indices stay visible (same rules as :func:`apply_viewer_visible_worlds`). + + Returns: + Selected indices, or ``None`` when all environments should be visible. + """ + if env_ids is not None: + return list(env_ids) + if env_selection_max_visible is not None and num_envs > 0: + n = min(int(env_selection_max_visible), num_envs) + return list(range(n)) + return None + + +def apply_viewer_visible_worlds( + viewer, + *, + env_ids: list[int] | None, + env_selection_max_visible: int | None, + num_envs: int, +) -> None: + """Select which simulation worlds are visualized; no-op if the viewer does not support it. + + Prefer this over ``set_model(..., max_worlds=...)`` (deprecated in Newton). + + Args: + viewer: Newton viewer (ViewerGL, ViewerRerun, ViewerViser, etc.). + env_ids: Explicit env indices from ``env_selection_*`` config, or ``None`` when showing all + unless :attr:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg.env_selection_max_visible` limits the count. + env_selection_max_visible: Optional cap on the number of worlds (``0..num_envs-1``) when ``env_ids`` is + ``None``. + num_envs: Total environment count from scene metadata. + """ + if not hasattr(viewer, "set_visible_worlds"): + return + resolved = resolve_visible_env_indices(env_ids, env_selection_max_visible, num_envs) + if resolved is None: + viewer.set_visible_worlds(None) + else: + viewer.set_visible_worlds(resolved) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py index 531b067104c1..a322f00f3552 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py @@ -20,6 +20,8 @@ from isaaclab.visualizers.base_visualizer import BaseVisualizer +from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds + from .rerun_visualizer_cfg import RerunVisualizerCfg if TYPE_CHECKING: @@ -145,16 +147,10 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._scene_data_provider = scene_data_provider metadata = scene_data_provider.get_metadata() + num_envs = int(metadata.get("num_envs", 0)) self._env_ids = self._compute_visualized_env_ids() - if self._env_ids: - get_filtered_model = getattr(scene_data_provider, "get_newton_model_for_env_ids", None) - if callable(get_filtered_model): - self._model = get_filtered_model(self._env_ids) - else: - self._model = scene_data_provider.get_newton_model() - else: - self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state(self._env_ids) + self._model = scene_data_provider.get_newton_model() + self._state = scene_data_provider.get_newton_state(None) grpc_port = int(self.cfg.grpc_port) web_port = int(self.cfg.web_port) @@ -185,7 +181,13 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: viewer_url = _rerun_web_viewer_url(viewer_host, web_port, rerun_address) if self.cfg.open_browser and not start_server_in_viewer: _open_rerun_web_viewer(viewer_host, web_port, rerun_address) - self._viewer.set_model(self._model, max_worlds=self.cfg.max_worlds) + self._viewer.set_model(self._model) + apply_viewer_visible_worlds( + self._viewer, + env_ids=self._env_ids, + env_selection_max_visible=self.cfg.env_selection_max_visible, + num_envs=num_envs, + ) # Preserve simulation world positions (env_spacing) rather than adding viewer-side offsets. self._viewer.set_world_offsets((0.0, 0.0, 0.0)) initial_pose = self._resolve_initial_camera_pose() @@ -231,7 +233,7 @@ def step(self, dt: float) -> None: if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() - self._state = self._scene_data_provider.get_newton_state(self._env_ids) + self._state = self._scene_data_provider.get_newton_state(None) if not self._viewer.is_paused(): self._viewer.begin_frame(self._sim_time) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer_cfg.py index 5edd918929de..780b346f802b 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer_cfg.py @@ -47,9 +47,3 @@ class RerunVisualizerCfg(VisualizerCfg): record_to_rrd: str | None = None """Path to save .rrd recording file. None = no recording.""" - - max_worlds: int | None = None - """Maximum number of worlds/environments rendered by the viewer. - - Set to ``None`` to leave this option disabled. - """ diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py index d6606403bba6..6ae70705d109 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py @@ -19,6 +19,8 @@ from isaaclab.visualizers.base_visualizer import BaseVisualizer +from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds + from .viser_visualizer_cfg import ViserVisualizerCfg logger = logging.getLogger(__name__) @@ -143,16 +145,8 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._scene_data_provider = scene_data_provider metadata = scene_data_provider.get_metadata() self._env_ids = self._compute_visualized_env_ids() - if self._env_ids: - get_filtered_model = getattr(scene_data_provider, "get_newton_model_for_env_ids", None) - self._model = ( - get_filtered_model(self._env_ids) - if callable(get_filtered_model) - else scene_data_provider.get_newton_model() - ) - else: - self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state(self._env_ids) + self._model = scene_data_provider.get_newton_model() + self._state = scene_data_provider.get_newton_state(None) self._active_record_path = self.cfg.record_to_viser self._create_viewer(record_to_viser=self.cfg.record_to_viser, metadata=metadata) @@ -186,7 +180,7 @@ def step(self, dt: float) -> None: self._update_camera_from_usd_path() self._apply_pending_camera_pose() - self._state = self._scene_data_provider.get_newton_state(self._env_ids) + self._state = self._scene_data_provider.get_newton_state(None) self._sim_time += dt self._viewer.begin_frame(self._sim_time) self._viewer.log_state(self._state) @@ -253,8 +247,14 @@ def _create_viewer(self, record_to_viser: str | None, metadata: dict | None = No record_to_viser=record_to_viser, metadata=metadata or {}, ) - max_worlds = self.cfg.max_worlds - self._viewer.set_model(self._model, max_worlds=max_worlds) + num_envs = int((metadata or {}).get("num_envs", 0)) + self._viewer.set_model(self._model) + apply_viewer_visible_worlds( + self._viewer, + env_ids=self._env_ids, + env_selection_max_visible=self.cfg.env_selection_max_visible, + num_envs=num_envs, + ) # Preserve simulation world positions (env_spacing) rather than adding viewer-side offsets. self._viewer.set_world_offsets((0.0, 0.0, 0.0)) if self.cfg.open_browser: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer_cfg.py index c2400c7ee1e6..f3f2aa39b0c2 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer_cfg.py @@ -35,9 +35,3 @@ class ViserVisualizerCfg(VisualizerCfg): record_to_viser: str | None = None """Path to save a .viser recording file. None = no recording.""" - - max_worlds: int | None = None - """Maximum number of worlds/environments rendered by the viewer. - - Set to ``None`` to leave this option disabled. - """ diff --git a/source/isaaclab_visualizers/setup.py b/source/isaaclab_visualizers/setup.py index 2dfe9abd30fa..642886ebe49f 100644 --- a/source/isaaclab_visualizers/setup.py +++ b/source/isaaclab_visualizers/setup.py @@ -17,16 +17,16 @@ "kit": [], "newton": [ "warp-lang", - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + "newton @ git+https://github.com/newton-physics/newton.git@7e036f542437046f2dc14028e189cb8428afd191", "PyOpenGL-accelerate", "imgui-bundle>=1.92.5", ], "rerun": [ - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + "newton @ git+https://github.com/newton-physics/newton.git@7e036f542437046f2dc14028e189cb8428afd191", "rerun-sdk>=0.29.0", ], "viser": [ - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + "newton @ git+https://github.com/newton-physics/newton.git@7e036f542437046f2dc14028e189cb8428afd191", "viser>=1.0.16", ], } diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py new file mode 100644 index 000000000000..299774551af5 --- /dev/null +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -0,0 +1,43 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for viewer env resolution helpers.""" + +from __future__ import annotations + +from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices + + +def test_resolve_visible_env_indices_env_ids_win(): + assert resolve_visible_env_indices([1, 3], 1, 10) == [1, 3] + + +def test_resolve_visible_env_indices_cap_when_no_filter(): + assert resolve_visible_env_indices(None, 3, 10) == [0, 1, 2] + + +def test_resolve_visible_env_indices_all_when_no_cap(): + assert resolve_visible_env_indices(None, None, 10) is None + + +def test_resolve_visible_env_indices_num_envs_zero_falls_through_like_newton(): + assert resolve_visible_env_indices(None, 5, 0) is None + + +def test_apply_viewer_visible_worlds_delegates_to_resolved(): + calls: list = [] + + class _V: + def set_visible_worlds(self, worlds): + calls.append(worlds) + + apply_viewer_visible_worlds(_V(), env_ids=None, env_selection_max_visible=2, num_envs=5) + assert calls == [[0, 1]] + + apply_viewer_visible_worlds(_V(), env_ids=[2], env_selection_max_visible=99, num_envs=5) + assert calls[-1] == [2] + + apply_viewer_visible_worlds(_V(), env_ids=None, env_selection_max_visible=None, num_envs=3) + assert calls[-1] is None From 6b38963a319954eba7f1bc9606c1be05fef3ce43 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Mon, 20 Apr 2026 23:48:16 +0000 Subject: [PATCH 05/37] wip --- docs/source/features/visualization.rst | 16 ++++++++ .../isaaclab/visualizers/visualizer_cfg.py | 11 +++++- .../test/visualizers/test_visualizer.py | 37 +++++++++++++++++++ .../test/test_newton_adapter.py | 1 + 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index c093fe18f00f..2c9ffa03d77e 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -139,6 +139,22 @@ The effective visualizer mode is resolved from both CLI and ``SimulationCfg.visu For the migration-focused summary and deprecation context, see :doc:`/source/migration/migrating_to_isaaclab_3-0`. +Partial visualization +~~~~~~~~~~~~~~~~~~~~~ + +:attr:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg.env_selection_mode` and related fields control which env +indices each **viewer** uses. ``env_selection_max_visible`` applies when the mode is ``none`` (cap indices ``0..N-1``). +``env_selection_random_count`` applies only when the mode is ``random_n``; it does not duplicate +``env_selection_max_visible``. + +**CLI vs config:** If you pass matching ``--viz_env_selection_*`` flags, Isaac Lab applies them **after** resolving +``SimulationCfg.visualizer_cfgs``, and those values **take precedence** over the same fields on each +:class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` for that run. + +- **Newton, Rerun, Viser:** Newton ``set_visible_worlds`` limits which worlds the viewer draws. +- **Kit (Omniverse):** Non-selected ``/World/envs/env_*`` prims are hidden via USD visibility. **This is not a reliable + performance optimization** in Kit today; it is primarily cosmetic. + .. _visualization-common-modes: .. list-table:: Common modes diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 350aacb4d3dc..cc91171c166e 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -47,7 +47,11 @@ class VisualizerCfg: """Absolute USD path to a camera prim when cam_source='prim_path'.""" env_selection_max_visible: int | None = 4 - """When ``env_selection_mode`` is ``none``, optional cap on how many envs are shown (``0..num_envs-1``).""" + """When ``env_selection_mode`` is ``none``, optional cap on how many envs are shown (indices ``0..min(cap,num_envs)-1``). + + Not used when ``env_selection_mode`` is ``env_ids`` or ``random_n`` (those modes use :attr:`env_selection_ids` or + :attr:`env_selection_random_count` instead). + """ env_selection_mode: Literal["none", "env_ids", "random_n"] = "none" """How env indices are chosen for viewers: ``none`` (use :attr:`env_selection_max_visible` only), ``env_ids``, or ``random_n``.""" @@ -57,7 +61,10 @@ class VisualizerCfg: """ env_selection_random_count: int = 64 - """When ``env_selection_mode`` is ``random_n``, number of env indices to sample.""" + """When ``env_selection_mode`` is ``random_n``, how many env indices to sample (with :attr:`env_selection_random_seed`). + + Unrelated to :attr:`env_selection_max_visible`, which applies only when ``env_selection_mode`` is ``none``. + """ env_selection_random_seed: int = 0 """Seed for deterministic sampling when ``env_selection_mode`` is ``random_n``.""" diff --git a/source/isaaclab/test/visualizers/test_visualizer.py b/source/isaaclab/test/visualizers/test_visualizer.py index 65cef261282f..865cfcc312e8 100644 --- a/source/isaaclab/test/visualizers/test_visualizer.py +++ b/source/isaaclab/test/visualizers/test_visualizer.py @@ -7,6 +7,7 @@ from __future__ import annotations +import importlib.util from types import SimpleNamespace import pytest @@ -62,6 +63,7 @@ def is_running(self) -> bool: def _make_cfg(**kwargs): cfg = { "env_selection_mode": "none", + "env_selection_max_visible": None, "env_selection_ids": [0, 2, 4], "env_selection_random_count": 2, "env_selection_random_seed": 7, @@ -70,6 +72,9 @@ def _make_cfg(**kwargs): return SimpleNamespace(**cfg) +_HAS_ISAACLAB_VIZ = importlib.util.find_spec("isaaclab_visualizers") is not None + + class _FakeProvider: def __init__(self, num_envs: int = 0, transforms: dict | None = None): self._num_envs = num_envs @@ -103,6 +108,38 @@ def test_compute_visualized_env_ids_random_n_is_deterministic(): assert viz_a._compute_visualized_env_ids() == viz_b._compute_visualized_env_ids() +@pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") +def test_partial_visualization_none_mode_uses_resolver_cap_not_random_count(): + """Mode ``none``: :meth:`_compute_visualized_env_ids` is None; cap comes from ``resolve_visible_env_indices``.""" + from isaaclab_visualizers.newton_adapter import resolve_visible_env_indices + + cfg = _make_cfg(env_selection_mode="none", env_selection_max_visible=3, env_selection_random_count=99) + viz = _DummyVisualizer(cfg) + viz._scene_data_provider = _FakeProvider(num_envs=10) + assert viz._compute_visualized_env_ids() is None + assert resolve_visible_env_indices(None, cfg.env_selection_max_visible, 10) == [0, 1, 2] + # random_count is ignored in this mode (would only apply if mode were random_n). + assert resolve_visible_env_indices(None, 3, 10) == [0, 1, 2] + + +@pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") +def test_partial_visualization_random_n_uses_compute_ids_resolver_ignores_cap(): + """Mode ``random_n``: explicit indices from base visualizer; ``env_selection_max_visible`` does not apply.""" + from isaaclab_visualizers.newton_adapter import resolve_visible_env_indices + + cfg = _make_cfg( + env_selection_mode="random_n", + env_selection_random_count=3, + env_selection_random_seed=0, + env_selection_max_visible=1, + ) + viz = _DummyVisualizer(cfg) + viz._scene_data_provider = _FakeProvider(num_envs=10) + ids = viz._compute_visualized_env_ids() + assert ids is not None and len(ids) == 3 + assert resolve_visible_env_indices(ids, cfg.env_selection_max_visible, 10) == list(ids) + + def test_resolve_camera_pose_from_usd_path_uses_provider_transforms(): transforms = { "order": ["/World/envs/env_%d/Camera"], diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index 299774551af5..5dda43f542a0 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -15,6 +15,7 @@ def test_resolve_visible_env_indices_env_ids_win(): def test_resolve_visible_env_indices_cap_when_no_filter(): + # When _compute_visualized_env_ids is None (e.g. mode ``none``), cap is env_selection_max_visible, not random_count. assert resolve_visible_env_indices(None, 3, 10) == [0, 1, 2] From dfb331ea28fa03440b8f0c534f3595282ae8d802 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Mon, 20 Apr 2026 23:59:23 +0000 Subject: [PATCH 06/37] reduce CLI args --- docs/source/features/visualization.rst | 19 +-- source/isaaclab/isaaclab/app/app_launcher.py | 108 ++---------------- .../isaaclab/sim/simulation_context.py | 45 +------- .../isaaclab/visualizers/visualizer_cfg.py | 10 +- source/isaaclab/test/app/test_kwarg_launch.py | 8 -- .../isaaclab_tasks/utils/sim_launcher.py | 2 +- .../kit/kit_visualizer.py | 4 +- .../isaaclab_visualizers/newton_adapter.py | 2 +- 8 files changed, 31 insertions(+), 167 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 2c9ffa03d77e..f632d5fa33dc 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -142,14 +142,17 @@ For the migration-focused summary and deprecation context, see Partial visualization ~~~~~~~~~~~~~~~~~~~~~ -:attr:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg.env_selection_mode` and related fields control which env -indices each **viewer** uses. ``env_selection_max_visible`` applies when the mode is ``none`` (cap indices ``0..N-1``). -``env_selection_random_count`` applies only when the mode is ``random_n``; it does not duplicate -``env_selection_max_visible``. - -**CLI vs config:** If you pass matching ``--viz_env_selection_*`` flags, Isaac Lab applies them **after** resolving -``SimulationCfg.visualizer_cfgs``, and those values **take precedence** over the same fields on each -:class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` for that run. +Partial visualization can be used to visualize a subset of envs to improve performance. + +``VisualizerCfg.env_selection_mode`` and related fields control which env indices each **viewer** uses. +``VisualizerCfg.env_selection_max_visible`` applies when the mode is ``none`` (cap indices ``0..N-1``). +``VisualizerCfg.env_selection_random_count`` applies only when the mode is ``random_n``; it does not duplicate +``VisualizerCfg.env_selection_max_visible`` (used in ``none`` mode). + +**CLI vs config (cap):** The only CLI flag that maps to ``env_selection_max_visible`` is +``--viz_env_selection_max_visible``. When you pass it, Isaac Lab applies it **after** resolving +``SimulationCfg.visualizer_cfgs``, and it **takes precedence** over ``VisualizerCfg.env_selection_max_visible`` for +that run. - **Newton, Rerun, Viser:** Newton ``set_visible_worlds`` limits which worlds the viewer draws. - **Kit (Omniverse):** Non-selected ``/World/envs/env_*`` prims are hidden via USD visibility. **This is not a reliable diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index b7090341eb15..abc01e214300 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -40,12 +40,11 @@ def sync_visualizer_cli_settings_to_carb( cli_explicit: bool | None = None, cli_disable_all: bool | None = None, ) -> None: - """Persist visualizer CLI flags (selection, env selection overrides) to carb settings. + """Persist visualizer CLI flags (selection, ``--viz_env_selection_max_visible``) to carb settings. - Optional Newton viewer arguments use :data:`argparse.SUPPRESS` defaults so only options the user - actually passed appear in *launcher_args*. We record ``cli_override/*`` booleans and only those - fields override :class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` in - :meth:`SimulationContext._apply_visualizer_cli_overrides`. + Optional arguments use :data:`argparse.SUPPRESS` defaults so only options the user actually passed + appear in *launcher_args*. We record ``cli_override/viz_env_selection_max_visible`` and + ``/isaaclab/visualizer/env_selection_max_visible`` for :meth:`SimulationContext._apply_visualizer_cli_overrides`. Used by :class:`AppLauncher` and by standalone Newton/Rerun/Viser flows that skip Kit (see :mod:`isaaclab_tasks.utils.sim_launcher`). @@ -55,17 +54,7 @@ def sync_visualizer_cli_settings_to_carb( if "viz_env_selection_max_visible" in launcher_args: v = launcher_args["viz_env_selection_max_visible"] if v is not None and int(v) < 0: - raise ValueError( - f"Invalid value for --viz_env_selection_max_visible: {v}. Expected non-negative int." - ) - - if "viz_env_selection_mode" in launcher_args: - mode_arg = launcher_args["viz_env_selection_mode"] - if mode_arg is not None and mode_arg not in ("none", "env_ids", "random_n"): - raise ValueError( - f"Invalid value for --viz_env_selection_mode: {mode_arg!r}. " - "Expected 'none', 'env_ids', or 'random_n'." - ) + raise ValueError(f"Invalid value for --viz_env_selection_max_visible: {v}. Expected non-negative int.") if cli_explicit is None: cli_explicit = bool(launcher_args.get("visualizer_explicit", False)) @@ -91,53 +80,6 @@ def sync_visualizer_cli_settings_to_carb( else: settings.set_int("/isaaclab/visualizer/env_selection_max_visible", -1) - settings.set_bool( - "/isaaclab/visualizer/cli_override/viz_env_selection_mode", - "viz_env_selection_mode" in launcher_args, - ) - if "viz_env_selection_mode" in launcher_args: - settings.set_string( - "/isaaclab/visualizer/env_selection_mode", str(launcher_args["viz_env_selection_mode"]) - ) - else: - settings.set_string("/isaaclab/visualizer/env_selection_mode", "") - - settings.set_bool( - "/isaaclab/visualizer/cli_override/viz_env_selection_ids", - "viz_env_selection_ids" in launcher_args, - ) - if "viz_env_selection_ids" in launcher_args: - settings.set_string( - "/isaaclab/visualizer/env_selection_ids", - str(launcher_args["viz_env_selection_ids"]).strip(), - ) - else: - settings.set_string("/isaaclab/visualizer/env_selection_ids", "") - - settings.set_bool( - "/isaaclab/visualizer/cli_override/viz_env_selection_random_count", - "viz_env_selection_random_count" in launcher_args, - ) - if "viz_env_selection_random_count" in launcher_args: - settings.set_int( - "/isaaclab/visualizer/env_selection_random_count", - int(launcher_args["viz_env_selection_random_count"]), - ) - else: - settings.set_int("/isaaclab/visualizer/env_selection_random_count", -1) - - settings.set_bool( - "/isaaclab/visualizer/cli_override/viz_env_selection_random_seed", - "viz_env_selection_random_seed" in launcher_args, - ) - if "viz_env_selection_random_seed" in launcher_args: - settings.set_int( - "/isaaclab/visualizer/env_selection_random_seed", - int(launcher_args["viz_env_selection_random_seed"]), - ) - else: - settings.set_int("/isaaclab/visualizer/env_selection_random_seed", -1) - # Suppress noisy debug-level logs from third-party libraries logging.getLogger("websockets").setLevel(logging.WARNING) @@ -435,11 +377,10 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: - Multiple visualizers can be specified as a comma-delimited list: ``--viz rerun,newton,viser``. - * ``viz_env_selection_max_visible`` (int | None): Optional global cap on how many envs each visualizer shows when - ``env_selection_mode`` is ``none`` (newton, rerun, viser, kit). If omitted, each visualizer uses its config default. - - * ``viz_env_selection_mode`` / ``viz_env_selection_ids`` / ``viz_env_selection_random_count`` / ``viz_env_selection_random_seed``: - Optional global overrides for :class:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg` env selection. + * ``viz_env_selection_max_visible`` (int | None): Optional global cap on how many envs each visualizer + shows when ``env_selection_mode`` is ``none`` (newton, rerun, viser, kit). If omitted, each visualizer + uses its config default. + Other ``VisualizerCfg`` env-selection fields are set only in Python config, not via AppLauncher CLI. .. _`WebRTC`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client @@ -599,33 +540,6 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "when ``env_selection_mode`` is ``none``. If omitted, task/visualizer config values are kept." ), ) - arg_group.add_argument( - "--viz_env_selection_mode", - type=str, - default=argparse.SUPPRESS, - help=( - "When set, overrides ``env_selection_mode`` on visualizer configs " - "(none | env_ids | random_n). If omitted, task/visualizer config values are kept." - ), - ) - arg_group.add_argument( - "--viz_env_selection_ids", - type=str, - default=argparse.SUPPRESS, - help="When set, overrides ``env_selection_ids`` (comma-separated, e.g. 0,2,5).", - ) - arg_group.add_argument( - "--viz_env_selection_random_count", - type=int, - default=argparse.SUPPRESS, - help="When set, overrides ``env_selection_random_count``.", - ) - arg_group.add_argument( - "--viz_env_selection_random_seed", - type=int, - default=argparse.SUPPRESS, - help="When set, overrides ``env_selection_random_seed``.", - ) # special flag for backwards compatibility # Corresponding to the beginning of the function, @@ -647,10 +561,6 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "experience": ([str], ""), "rendering_mode": ([str], "balanced"), "viz_env_selection_max_visible": ([int, type(None)], None), - "viz_env_selection_mode": ([str, type(None)], None), - "viz_env_selection_ids": ([str, type(None)], None), - "viz_env_selection_random_count": ([int, type(None)], None), - "viz_env_selection_random_seed": ([int, type(None)], None), } """A dictionary of arguments added manually by the :meth:`AppLauncher.add_app_launcher_args` method. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 8835e0cc7220..5d11e8aff8d7 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -447,7 +447,7 @@ def _cli_visualizer_field_overridden(self, field: str) -> bool: return False def _get_cli_visualizer_env_selection_max_visible_override(self) -> tuple[bool, int | None]: - """Return CLI override for ``env_selection_max_visible`` when the user passed ``--viz_env_selection_max_visible``.""" + """CLI override for ``env_selection_max_visible`` when ``--viz_env_selection_max_visible`` is set.""" if not self._cli_visualizer_field_overridden("viz_env_selection_max_visible"): return False, None value = self.get_setting("/isaaclab/visualizer/env_selection_max_visible") @@ -464,21 +464,6 @@ def _get_cli_visualizer_env_selection_max_visible_override(self) -> tuple[bool, return False, None return True, max_visible - def _parse_cli_env_selection_ids_setting(self) -> list[int]: - ids_raw = self.get_setting("/isaaclab/visualizer/env_selection_ids") - if ids_raw is None or not str(ids_raw).strip(): - return [] - parts = [p.strip() for p in str(ids_raw).split(",") if p.strip()] - parsed: list[int] = [] - for p in parts: - try: - parsed.append(int(p)) - except ValueError: - logger.warning( - "[SimulationContext] Invalid env id in /isaaclab/visualizer/env_selection_ids: %r", p - ) - return parsed - def _apply_visualizer_cli_overrides(self, visualizer_cfgs: list[Any]) -> None: """Apply CLI visualizer overrides to resolved configs (only fields the user set on the CLI).""" has_max, max_visible_override = self._get_cli_visualizer_env_selection_max_visible_override() @@ -487,34 +472,6 @@ def _apply_visualizer_cli_overrides(self, visualizer_cfgs: list[Any]) -> None: if hasattr(cfg, "env_selection_max_visible"): cfg.env_selection_max_visible = max_visible_override - for cfg in visualizer_cfgs: - if not hasattr(cfg, "env_selection_mode"): - continue - if self._cli_visualizer_field_overridden("viz_env_selection_mode"): - mode = self.get_setting("/isaaclab/visualizer/env_selection_mode") - if mode is not None and str(mode).strip(): - cfg.env_selection_mode = str(mode).strip() - if self._cli_visualizer_field_overridden("viz_env_selection_ids"): - cfg.env_selection_ids = list(self._parse_cli_env_selection_ids_setting()) - if self._cli_visualizer_field_overridden("viz_env_selection_random_count"): - rn = self.get_setting("/isaaclab/visualizer/env_selection_random_count") - if rn is not None: - try: - cfg.env_selection_random_count = int(rn) - except (TypeError, ValueError): - logger.warning( - "[SimulationContext] Invalid /isaaclab/visualizer/env_selection_random_count: %r", rn - ) - if self._cli_visualizer_field_overridden("viz_env_selection_random_seed"): - seed = self.get_setting("/isaaclab/visualizer/env_selection_random_seed") - if seed is not None: - try: - cfg.env_selection_random_seed = int(seed) - except (TypeError, ValueError): - logger.warning( - "[SimulationContext] Invalid /isaaclab/visualizer/env_selection_random_seed: %r", seed - ) - def _is_cli_visualizer_explicit(self) -> bool: """Return ``True`` when visualizers were explicitly provided via CLI.""" return bool(self.get_setting("/isaaclab/visualizer/explicit")) diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index cc91171c166e..8ee5fb78f12f 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -47,21 +47,25 @@ class VisualizerCfg: """Absolute USD path to a camera prim when cam_source='prim_path'.""" env_selection_max_visible: int | None = 4 - """When ``env_selection_mode`` is ``none``, optional cap on how many envs are shown (indices ``0..min(cap,num_envs)-1``). + """When ``env_selection_mode`` is ``none``, optional cap on how many envs are shown + (indices ``0..min(cap,num_envs)-1``). Not used when ``env_selection_mode`` is ``env_ids`` or ``random_n`` (those modes use :attr:`env_selection_ids` or :attr:`env_selection_random_count` instead). """ env_selection_mode: Literal["none", "env_ids", "random_n"] = "none" - """How env indices are chosen for viewers: ``none`` (use :attr:`env_selection_max_visible` only), ``env_ids``, or ``random_n``.""" + """How env indices are chosen for viewers: ``none`` (use :attr:`env_selection_max_visible` only), + ``env_ids``, or ``random_n``. + """ env_selection_ids: list[int] = [i for i in range(0, 64, 4)] """When ``env_selection_mode`` is ``env_ids``, only these env indices are shown. """ env_selection_random_count: int = 64 - """When ``env_selection_mode`` is ``random_n``, how many env indices to sample (with :attr:`env_selection_random_seed`). + """When ``env_selection_mode`` is ``random_n``, how many env indices to sample + (with :attr:`env_selection_random_seed`). Unrelated to :attr:`env_selection_max_visible`, which applies only when ``env_selection_mode`` is ``none``. """ diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index c4600299ca1f..661e3f4a71b4 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -51,14 +51,6 @@ def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): "/isaaclab/visualizer/disable_all": False, "/isaaclab/visualizer/cli_override/viz_env_selection_max_visible": True, "/isaaclab/visualizer/env_selection_max_visible": 0, - "/isaaclab/visualizer/cli_override/viz_env_selection_mode": False, - "/isaaclab/visualizer/env_selection_mode": "", - "/isaaclab/visualizer/cli_override/viz_env_selection_ids": False, - "/isaaclab/visualizer/env_selection_ids": "", - "/isaaclab/visualizer/cli_override/viz_env_selection_random_count": False, - "/isaaclab/visualizer/env_selection_random_count": -1, - "/isaaclab/visualizer/cli_override/viz_env_selection_random_seed": False, - "/isaaclab/visualizer/env_selection_random_seed": -1, } diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index 0fd5c3d21912..19b45dbf426b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -212,7 +212,7 @@ def launch_simulation( close_fn = app_launcher.app.close elif visualizer_types: # Newton path without Kit: AppLauncher is skipped — persist the same visualizer CLI - # settings (types, env_selection_*, viz_env_selection_*) that AppLauncher would write. + # settings (types, viz_env_selection_max_visible) that AppLauncher would write. from isaaclab.app.app_launcher import sync_visualizer_cli_settings_to_carb if isinstance(launcher_args, argparse.Namespace): diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index f74d7edc3c62..0c7f15e62ee2 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -85,9 +85,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: ) self._apply_env_visibility(usd_stage, metadata, self._resolved_visible_env_ids) num_visualized_envs = ( - len(self._resolved_visible_env_ids) - if self._resolved_visible_env_ids is not None - else num_envs_meta + len(self._resolved_visible_env_ids) if self._resolved_visible_env_ids is not None else num_envs_meta ) self._log_initialization_table( logger=logger, diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py index a8f7a8a40514..d9a39be376fa 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py @@ -40,7 +40,7 @@ def apply_viewer_visible_worlds( Args: viewer: Newton viewer (ViewerGL, ViewerRerun, ViewerViser, etc.). env_ids: Explicit env indices from ``env_selection_*`` config, or ``None`` when showing all - unless :attr:`~isaaclab.visualizers.visualizer_cfg.VisualizerCfg.env_selection_max_visible` limits the count. + unless ``env_selection_max_visible`` limits the count (see ``VisualizerCfg``). env_selection_max_visible: Optional cap on the number of worlds (``0..num_envs-1``) when ``env_ids`` is ``None``. num_envs: Total environment count from scene metadata. From 9b95dbbe05f7c9db3939a78a9624881218ff8f33 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Tue, 21 Apr 2026 00:48:24 +0000 Subject: [PATCH 07/37] simplify --- docs/source/features/visualization.rst | 19 ++--- source/isaaclab/isaaclab/app/app_launcher.py | 76 ++++++++++++++----- .../isaaclab/sim/simulation_context.py | 22 +++--- .../isaaclab/visualizers/base_visualizer.py | 19 +---- .../isaaclab/visualizers/visualizer_cfg.py | 29 ++----- source/isaaclab/test/app/test_kwarg_launch.py | 14 ++-- .../test_simulation_context_visualizers.py | 26 +++---- .../test/visualizers/test_visualizer.py | 52 +++++-------- .../isaaclab_tasks/utils/sim_launcher.py | 2 +- .../kit/kit_visualizer.py | 4 +- .../newton/newton_visualizer.py | 7 +- .../isaaclab_visualizers/newton_adapter.py | 40 +++++++--- .../rerun/rerun_visualizer.py | 7 +- .../viser/viser_visualizer.py | 8 +- .../test/test_newton_adapter.py | 17 +++-- 15 files changed, 180 insertions(+), 162 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index f632d5fa33dc..7f4754d2ffc6 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -144,15 +144,16 @@ Partial visualization Partial visualization can be used to visualize a subset of envs to improve performance. -``VisualizerCfg.env_selection_mode`` and related fields control which env indices each **viewer** uses. -``VisualizerCfg.env_selection_max_visible`` applies when the mode is ``none`` (cap indices ``0..N-1``). -``VisualizerCfg.env_selection_random_count`` applies only when the mode is ``random_n``; it does not duplicate -``VisualizerCfg.env_selection_max_visible`` (used in ``none`` mode). - -**CLI vs config (cap):** The only CLI flag that maps to ``env_selection_max_visible`` is -``--viz_env_selection_max_visible``. When you pass it, Isaac Lab applies it **after** resolving -``SimulationCfg.visualizer_cfgs``, and it **takes precedence** over ``VisualizerCfg.env_selection_max_visible`` for -that run. +``max_visible_envs`` limits how many envs are shown. If ``visible_env_indices`` is ``None``, it uses contiguous +indices ``0 .. min(cap, num_envs) - 1``. If ``visible_env_indices`` is set, valid indices are kept in order, then the +list is **truncated from the end** if it has more than *cap* entries. Set ``max_visible_envs`` to ``None`` for no cap +on that side (full contiguous range, or the full explicit list). + +``visible_env_indices`` lists env indices in preference order. Set to ``None`` to use only the contiguous cap above. + +**CLI vs cap:** The only related CLI flag is ``--max_visible_envs``. It overrides ``VisualizerCfg.max_visible_envs`` +for the run and therefore applies both to the contiguous case and as the truncation length for explicit index lists. +``visible_env_indices`` is config-only (not a CLI flag). - **Newton, Rerun, Viser:** Newton ``set_visible_worlds`` limits which worlds the viewer draws. - **Kit (Omniverse):** Non-selected ``/World/envs/env_*`` prims are hidden via USD visibility. **This is not a reliable diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index abc01e214300..cc5a03dc56af 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -40,21 +40,21 @@ def sync_visualizer_cli_settings_to_carb( cli_explicit: bool | None = None, cli_disable_all: bool | None = None, ) -> None: - """Persist visualizer CLI flags (selection, ``--viz_env_selection_max_visible``) to carb settings. + """Persist visualizer CLI flags (selection, ``--max_visible_envs``) to carb settings. Optional arguments use :data:`argparse.SUPPRESS` defaults so only options the user actually passed - appear in *launcher_args*. We record ``cli_override/viz_env_selection_max_visible`` and - ``/isaaclab/visualizer/env_selection_max_visible`` for :meth:`SimulationContext._apply_visualizer_cli_overrides`. + appear in *launcher_args*. We record ``cli_override/max_visible_envs`` and + ``/isaaclab/visualizer/max_visible_envs`` for :meth:`SimulationContext._apply_visualizer_cli_overrides`. Used by :class:`AppLauncher` and by standalone Newton/Rerun/Viser flows that skip Kit (see :mod:`isaaclab_tasks.utils.sim_launcher`). """ visualizers = launcher_args.get("visualizer") - if "viz_env_selection_max_visible" in launcher_args: - v = launcher_args["viz_env_selection_max_visible"] + if "max_visible_envs" in launcher_args: + v = launcher_args["max_visible_envs"] if v is not None and int(v) < 0: - raise ValueError(f"Invalid value for --viz_env_selection_max_visible: {v}. Expected non-negative int.") + raise ValueError(f"Invalid value for --max_visible_envs: {v}. Expected non-negative int.") if cli_explicit is None: cli_explicit = bool(launcher_args.get("visualizer_explicit", False)) @@ -69,16 +69,16 @@ def sync_visualizer_cli_settings_to_carb( settings.set_bool("/isaaclab/visualizer/disable_all", cli_disable_all) settings.set_bool( - "/isaaclab/visualizer/cli_override/viz_env_selection_max_visible", - "viz_env_selection_max_visible" in launcher_args, + "/isaaclab/visualizer/cli_override/max_visible_envs", + "max_visible_envs" in launcher_args, ) - if "viz_env_selection_max_visible" in launcher_args: + if "max_visible_envs" in launcher_args: settings.set_int( - "/isaaclab/visualizer/env_selection_max_visible", - int(launcher_args["viz_env_selection_max_visible"]), + "/isaaclab/visualizer/max_visible_envs", + int(launcher_args["max_visible_envs"]), ) else: - settings.set_int("/isaaclab/visualizer/env_selection_max_visible", -1) + settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) # Suppress noisy debug-level logs from third-party libraries @@ -377,10 +377,9 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: - Multiple visualizers can be specified as a comma-delimited list: ``--viz rerun,newton,viser``. - * ``viz_env_selection_max_visible`` (int | None): Optional global cap on how many envs each visualizer - shows when ``env_selection_mode`` is ``none`` (newton, rerun, viser, kit). If omitted, each visualizer - uses its config default. - Other ``VisualizerCfg`` env-selection fields are set only in Python config, not via AppLauncher CLI. + * ``max_visible_envs`` (int | None): Overrides ``VisualizerCfg.max_visible_envs`` for the run: + contiguous env count when ``visible_env_indices`` is unset, or truncation length for explicit index lists + (newton, rerun, viser, kit). ``visible_env_indices`` is config-only, not a CLI flag. .. _`WebRTC`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client @@ -532,12 +531,12 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: ), ) arg_group.add_argument( - "--viz_env_selection_max_visible", + "--max_visible_envs", type=int, default=argparse.SUPPRESS, help=( - "When set, overrides ``env_selection_max_visible`` on visualizer configs (newton/rerun/viser/kit) " - "when ``env_selection_mode`` is ``none``. If omitted, task/visualizer config values are kept." + "When set, overrides ``max_visible_envs``: contiguous count when ``visible_env_indices`` is unset, " + "or max length of an explicit index list (truncates from the end). If omitted, config values apply." ), ) # special flag for backwards compatibility @@ -560,7 +559,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "device": ([str], "cuda:0"), "experience": ([str], ""), "rendering_mode": ([str], "balanced"), - "viz_env_selection_max_visible": ([int, type(None)], None), + "max_visible_envs": ([int, type(None)], None), } """A dictionary of arguments added manually by the :meth:`AppLauncher.add_app_launcher_args` method. @@ -1198,6 +1197,42 @@ def _set_animation_recording_settings(self, launcher_args: dict) -> None: settings.set_float("/isaaclab/anim_recording/start_time", start_time) settings.set_float("/isaaclab/anim_recording/stop_time", stop_time) + def _warn_if_max_visible_envs_unused(self, launcher_args: dict) -> None: + """Log when ``--max_visible_envs`` cannot affect any running visualizer for this process.""" + if "max_visible_envs" not in launcher_args: + return + + disable_all = getattr(self, "_cli_visualizer_disable_all", False) + explicit = getattr(self, "_cli_visualizer_explicit", False) + cfg_has_any = getattr(self, "_cfg_has_any_visualizers", False) + cli_types = getattr(self, "_cli_visualizer_types", []) + + if disable_all: + logger.warning( + "[AppLauncher] --max_visible_envs was set but all visualizers are disabled " + "(for example ``--viz none`` or deprecated ``--headless`` with ``--viz``); " + "the value is not applied." + ) + return + + if explicit: + if cli_types: + return + logger.warning( + "[AppLauncher] --max_visible_envs was set but no visualizers are selected on the CLI; " + "the value is not applied unless ``SimulationCfg.visualizer_cfgs`` configures visualizers." + ) + return + + if cfg_has_any: + return + + logger.warning( + "[AppLauncher] --max_visible_envs was set but no visualizers are configured for this run " + "(pass ``--viz `` and/or set ``SimulationCfg.visualizer_cfgs``); " + "the value is not applied." + ) + def _set_visualizer_settings(self, launcher_args: dict) -> None: """Store visualizer selection and Newton viewer CLI overrides in settings.""" sync_visualizer_cli_settings_to_carb( @@ -1205,6 +1240,7 @@ def _set_visualizer_settings(self, launcher_args: dict) -> None: cli_explicit=getattr(self, "_cli_visualizer_explicit", False), cli_disable_all=getattr(self, "_cli_visualizer_disable_all", False), ) + self._warn_if_max_visible_envs_unused(launcher_args) def _interrupt_signal_handle_callback(self, signal, frame): """Handle the interrupt signal from the keyboard.""" diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 5d11e8aff8d7..358c0d1b4088 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -435,9 +435,9 @@ def _cli_visualizer_field_overridden(self, field: str) -> bool: v = self.get_setting(f"/isaaclab/visualizer/cli_override/{field}") if v is not None: return bool(v) - # Legacy: before cli_override existed, a non-negative env_selection_max_visible int implied CLI intent. - if field == "viz_env_selection_max_visible": - raw = self.get_setting("/isaaclab/visualizer/env_selection_max_visible") + # Legacy: before cli_override existed, a non-negative max_visible_envs int implied CLI intent. + if field == "max_visible_envs": + raw = self.get_setting("/isaaclab/visualizer/max_visible_envs") if raw is None: return False try: @@ -446,18 +446,18 @@ def _cli_visualizer_field_overridden(self, field: str) -> bool: return False return False - def _get_cli_visualizer_env_selection_max_visible_override(self) -> tuple[bool, int | None]: - """CLI override for ``env_selection_max_visible`` when ``--viz_env_selection_max_visible`` is set.""" - if not self._cli_visualizer_field_overridden("viz_env_selection_max_visible"): + def _get_cli_max_visible_envs_override(self) -> tuple[bool, int | None]: + """CLI override for ``max_visible_envs`` when ``--max_visible_envs`` is set.""" + if not self._cli_visualizer_field_overridden("max_visible_envs"): return False, None - value = self.get_setting("/isaaclab/visualizer/env_selection_max_visible") + value = self.get_setting("/isaaclab/visualizer/max_visible_envs") if value is None: return False, None try: max_visible = int(value) except (TypeError, ValueError): logger.warning( - "[SimulationContext] Invalid /isaaclab/visualizer/env_selection_max_visible setting: %r", value + "[SimulationContext] Invalid /isaaclab/visualizer/max_visible_envs setting: %r", value ) return False, None if max_visible < 0: @@ -466,11 +466,11 @@ def _get_cli_visualizer_env_selection_max_visible_override(self) -> tuple[bool, def _apply_visualizer_cli_overrides(self, visualizer_cfgs: list[Any]) -> None: """Apply CLI visualizer overrides to resolved configs (only fields the user set on the CLI).""" - has_max, max_visible_override = self._get_cli_visualizer_env_selection_max_visible_override() + has_max, max_visible_override = self._get_cli_max_visible_envs_override() if has_max: for cfg in visualizer_cfgs: - if hasattr(cfg, "env_selection_max_visible"): - cfg.env_selection_max_visible = max_visible_override + if hasattr(cfg, "max_visible_envs"): + cfg.max_visible_envs = max_visible_override def _is_cli_visualizer_explicit(self) -> bool: """Return ``True`` when visualizers were explicitly provided via CLI.""" diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index 15049371448d..c4a75420a472 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -8,7 +8,6 @@ from __future__ import annotations import logging -import random import re from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any @@ -147,26 +146,12 @@ def _compute_visualized_env_ids(self) -> list[int] | None: if self._scene_data_provider is None: return None cfg = self.cfg - if cfg.env_selection_mode == "none": - return None - num_envs = self._scene_data_provider.get_metadata().get("num_envs", 0) if num_envs <= 0: logger.debug("[Visualizer] num_envs is 0 or missing from provider metadata; env selection disabled.") return None - if cfg.env_selection_mode == "env_ids": - if len(cfg.env_selection_ids) > 0: - return [i for i in cfg.env_selection_ids if 0 <= i < num_envs] - return None - if cfg.env_selection_mode == "random_n": - count = int(cfg.env_selection_random_count) - if count <= 0: - return None - count = min(count, num_envs) - seed = int(cfg.env_selection_random_seed) - rng = random.Random(seed) - return sorted(rng.sample(range(num_envs), count)) - logger.warning("[Visualizer] Unknown env_selection_mode='%s'; defaulting to all envs.", cfg.env_selection_mode) + if cfg.visible_env_indices is not None: + return [i for i in cfg.visible_env_indices if 0 <= i < num_envs] return None def get_rendering_dt(self) -> float | None: diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 8ee5fb78f12f..2b3309b0a8d7 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -46,32 +46,15 @@ class VisualizerCfg: cam_prim_path: str = "/World/envs/env_0/Camera" """Absolute USD path to a camera prim when cam_source='prim_path'.""" - env_selection_max_visible: int | None = 4 - """When ``env_selection_mode`` is ``none``, optional cap on how many envs are shown - (indices ``0..min(cap,num_envs)-1``). + max_visible_envs: int | None = 4 + """Upper bound on how many envs are shown. - Not used when ``env_selection_mode`` is ``env_ids`` or ``random_n`` (those modes use :attr:`env_selection_ids` or - :attr:`env_selection_random_count` instead). + * If visible_env_indices is not None, then this field will apply also + to the explicit env indices set to the visible_env_indices. """ - env_selection_mode: Literal["none", "env_ids", "random_n"] = "none" - """How env indices are chosen for viewers: ``none`` (use :attr:`env_selection_max_visible` only), - ``env_ids``, or ``random_n``. - """ - - env_selection_ids: list[int] = [i for i in range(0, 64, 4)] - """When ``env_selection_mode`` is ``env_ids``, only these env indices are shown. - """ - - env_selection_random_count: int = 64 - """When ``env_selection_mode`` is ``random_n``, how many env indices to sample - (with :attr:`env_selection_random_seed`). - - Unrelated to :attr:`env_selection_max_visible`, which applies only when ``env_selection_mode`` is ``none``. - """ - - env_selection_random_seed: int = 0 - """Seed for deterministic sampling when ``env_selection_mode`` is ``random_n``.""" + visible_env_indices: list[int] | None = None + """env indices to visualize in order (out-of-range indices are dropped).""" def get_visualizer_type(self) -> str | None: """Get the visualizer type identifier. diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index 661e3f4a71b4..dbacba8adfa1 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -43,18 +43,18 @@ def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(app_launcher_module, "get_settings_manager", lambda: settings) launcher = AppLauncher.__new__(AppLauncher) - launcher._set_visualizer_settings({"visualizer": ["viser", "rerun"], "viz_env_selection_max_visible": 0}) + launcher._set_visualizer_settings({"visualizer": ["viser", "rerun"], "max_visible_envs": 0}) assert settings.values == { "/isaaclab/visualizer/types": "viser rerun", "/isaaclab/visualizer/explicit": False, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/cli_override/viz_env_selection_max_visible": True, - "/isaaclab/visualizer/env_selection_max_visible": 0, + "/isaaclab/visualizer/cli_override/max_visible_envs": True, + "/isaaclab/visualizer/max_visible_envs": 0, } -def test_set_visualizer_settings_rejects_negative_viz_env_selection_max_visible( +def test_set_visualizer_settings_rejects_negative_max_visible_envs( monkeypatch: pytest.MonkeyPatch, ): def _unexpected_settings_manager(): @@ -63,8 +63,8 @@ def _unexpected_settings_manager(): monkeypatch.setattr(app_launcher_module, "get_settings_manager", _unexpected_settings_manager) launcher = AppLauncher.__new__(AppLauncher) - with pytest.raises(ValueError, match="Invalid value for --viz_env_selection_max_visible: -5"): - launcher._set_visualizer_settings({"visualizer": ["viser"], "viz_env_selection_max_visible": -5}) + with pytest.raises(ValueError, match="Invalid value for --max_visible_envs: -5"): + launcher._set_visualizer_settings({"visualizer": ["viser"], "max_visible_envs": -5}) def test_set_visualizer_settings_suppresses_settings_manager_errors(monkeypatch: pytest.MonkeyPatch): @@ -74,7 +74,7 @@ def _raise_settings_error(): monkeypatch.setattr(app_launcher_module, "get_settings_manager", _raise_settings_error) launcher = AppLauncher.__new__(AppLauncher) - launcher._set_visualizer_settings({"visualizer": ["viser"], "viz_env_selection_max_visible": 3}) + launcher._set_visualizer_settings({"visualizer": ["viser"], "max_visible_envs": 3}) def test_parse_visualizer_csv_accepts_comma_delimited_values(): diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 31a23b8db1f8..1f9a794ef1d9 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -224,7 +224,7 @@ def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None @pytest.mark.parametrize( - ("cfg_env_selection_max_visible", "expected_visible"), + ("cfg_max_visible_envs", "expected_visible"), [ (None, None), (0, []), @@ -233,7 +233,7 @@ def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None ) def test_viser_visualizer_create_viewer_applies_visible_worlds( monkeypatch: pytest.MonkeyPatch, - cfg_env_selection_max_visible: int | None, + cfg_max_visible_envs: int | None, expected_visible: list[int] | None, ): captured = {} @@ -275,7 +275,7 @@ def set_world_offsets(self, spacing) -> None: ) monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_set_viser_camera_view", lambda self, pose: None) - cfg = ViserVisualizerCfg(env_selection_max_visible=cfg_env_selection_max_visible, open_browser=False) + cfg = ViserVisualizerCfg(max_visible_envs=cfg_max_visible_envs, open_browser=False) visualizer = viser_visualizer.ViserVisualizer(cfg) visualizer._model = "dummy-model" visualizer._env_ids = None # normally set by initialize() -> _compute_visualized_env_ids() @@ -287,7 +287,7 @@ def set_world_offsets(self, spacing) -> None: @pytest.mark.parametrize( - ("cfg_env_selection_max_visible", "expected_visible"), + ("cfg_max_visible_envs", "expected_visible"), [ (None, None), (0, []), @@ -296,7 +296,7 @@ def set_world_offsets(self, spacing) -> None: ) def test_rerun_visualizer_initialize_applies_visible_worlds_and_world_offsets( monkeypatch: pytest.MonkeyPatch, - cfg_env_selection_max_visible: int | None, + cfg_max_visible_envs: int | None, expected_visible: list[int] | None, ): captured = {} @@ -359,7 +359,7 @@ def get_newton_state(self, env_ids: list[int] | None): ) monkeypatch.setattr(rerun_visualizer.RerunVisualizer, "_apply_camera_pose", lambda self, pose: None) - cfg = RerunVisualizerCfg(open_browser=False, env_selection_max_visible=cfg_env_selection_max_visible) + cfg = RerunVisualizerCfg(open_browser=False, max_visible_envs=cfg_max_visible_envs) visualizer = rerun_visualizer.RerunVisualizer(cfg) visualizer.initialize(cast(Any, _DummyRerunSceneDataProvider())) @@ -469,7 +469,7 @@ def test_explicit_unknown_visualizer_type_raises(): "/isaaclab/visualizer/types": "bogus_viz", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/env_selection_max_visible": None, + "/isaaclab/visualizer/max_visible_envs": None, } ctx = _make_context_with_settings(settings) @@ -483,7 +483,7 @@ def test_explicit_missing_package_raises(monkeypatch: pytest.MonkeyPatch): "/isaaclab/visualizer/types": "rerun", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/env_selection_max_visible": None, + "/isaaclab/visualizer/max_visible_envs": None, } ctx = _make_context_with_settings(settings) @@ -510,7 +510,7 @@ def test_explicit_visualizer_create_failure_raises(monkeypatch: pytest.MonkeyPat "/isaaclab/visualizer/types": "newton", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/env_selection_max_visible": None, + "/isaaclab/visualizer/max_visible_envs": None, } ctx = _make_context_with_settings(settings, visualizer_cfgs=[failing_cfg]) @@ -529,7 +529,7 @@ def test_explicit_visualizer_init_failure_raises(monkeypatch: pytest.MonkeyPatch "/isaaclab/visualizer/types": "newton", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/env_selection_max_visible": None, + "/isaaclab/visualizer/max_visible_envs": None, } ctx = _make_context_with_settings(settings, visualizer_cfgs=[failing_cfg]) @@ -547,7 +547,7 @@ def test_explicit_partial_valid_types_raises_for_invalid(): "/isaaclab/visualizer/types": "newton,bogus_viz", "/isaaclab/visualizer/explicit": True, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/env_selection_max_visible": None, + "/isaaclab/visualizer/max_visible_envs": None, } ctx = _make_context_with_settings(settings) @@ -561,7 +561,7 @@ def test_non_explicit_unknown_type_silently_skipped(caplog): "/isaaclab/visualizer/types": "bogus_viz", "/isaaclab/visualizer/explicit": False, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/env_selection_max_visible": None, + "/isaaclab/visualizer/max_visible_envs": None, } ctx = _make_context_with_settings(settings) @@ -577,7 +577,7 @@ def test_non_explicit_create_failure_silently_logged(monkeypatch: pytest.MonkeyP "/isaaclab/visualizer/types": "", "/isaaclab/visualizer/explicit": False, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/env_selection_max_visible": None, + "/isaaclab/visualizer/max_visible_envs": None, } ctx = _make_context_with_settings(settings, visualizer_cfgs=[failing_cfg]) diff --git a/source/isaaclab/test/visualizers/test_visualizer.py b/source/isaaclab/test/visualizers/test_visualizer.py index 865cfcc312e8..96b4b4fb46af 100644 --- a/source/isaaclab/test/visualizers/test_visualizer.py +++ b/source/isaaclab/test/visualizers/test_visualizer.py @@ -62,11 +62,8 @@ def is_running(self) -> bool: def _make_cfg(**kwargs): cfg = { - "env_selection_mode": "none", - "env_selection_max_visible": None, - "env_selection_ids": [0, 2, 4], - "env_selection_random_count": 2, - "env_selection_random_seed": 7, + "max_visible_envs": None, + "visible_env_indices": None, } cfg.update(kwargs) return SimpleNamespace(**cfg) @@ -87,57 +84,46 @@ def get_camera_transforms(self): return self._transforms -def test_compute_visualized_env_ids_none_mode(): - viz = _DummyVisualizer(_make_cfg(env_selection_mode="none")) +def test_compute_visualized_env_ids_cap_only_returns_none(): + """Cap-only path: :meth:`_compute_visualized_env_ids` is ``None``. + + The cap is applied later by ``resolve_visible_env_indices``. + """ + viz = _DummyVisualizer(_make_cfg(visible_env_indices=None)) viz._scene_data_provider = _FakeProvider(num_envs=8) assert viz._compute_visualized_env_ids() is None -def test_compute_visualized_env_ids_from_ids_filters_out_of_range(): - viz = _DummyVisualizer(_make_cfg(env_selection_mode="env_ids", env_selection_ids=[-1, 0, 3, 99])) +def test_compute_visualized_env_ids_from_visible_indices_filters_out_of_range(): + viz = _DummyVisualizer(_make_cfg(visible_env_indices=[-1, 0, 3, 99])) viz._scene_data_provider = _FakeProvider(num_envs=4) assert viz._compute_visualized_env_ids() == [0, 3] -def test_compute_visualized_env_ids_random_n_is_deterministic(): - cfg = _make_cfg(env_selection_mode="random_n", env_selection_random_count=3, env_selection_random_seed=123) - viz_a = _DummyVisualizer(cfg) - viz_b = _DummyVisualizer(cfg) - viz_a._scene_data_provider = _FakeProvider(num_envs=10) - viz_b._scene_data_provider = _FakeProvider(num_envs=10) - assert viz_a._compute_visualized_env_ids() == viz_b._compute_visualized_env_ids() - - @pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") -def test_partial_visualization_none_mode_uses_resolver_cap_not_random_count(): - """Mode ``none``: :meth:`_compute_visualized_env_ids` is None; cap comes from ``resolve_visible_env_indices``.""" +def test_partial_visualization_cap_only_uses_resolver(): + """With ``visible_env_indices`` unset, :func:`resolve_visible_env_indices` applies ``max_visible_envs``.""" from isaaclab_visualizers.newton_adapter import resolve_visible_env_indices - cfg = _make_cfg(env_selection_mode="none", env_selection_max_visible=3, env_selection_random_count=99) + cfg = _make_cfg(max_visible_envs=3, visible_env_indices=None) viz = _DummyVisualizer(cfg) viz._scene_data_provider = _FakeProvider(num_envs=10) assert viz._compute_visualized_env_ids() is None - assert resolve_visible_env_indices(None, cfg.env_selection_max_visible, 10) == [0, 1, 2] - # random_count is ignored in this mode (would only apply if mode were random_n). + assert resolve_visible_env_indices(None, cfg.max_visible_envs, 10) == [0, 1, 2] assert resolve_visible_env_indices(None, 3, 10) == [0, 1, 2] @pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") -def test_partial_visualization_random_n_uses_compute_ids_resolver_ignores_cap(): - """Mode ``random_n``: explicit indices from base visualizer; ``env_selection_max_visible`` does not apply.""" +def test_explicit_visible_env_indices_truncated_by_max_visible_envs(): + """Explicit indices from :meth:`_compute_visualized_env_ids`; ``max_visible_envs`` truncates from the end.""" from isaaclab_visualizers.newton_adapter import resolve_visible_env_indices - cfg = _make_cfg( - env_selection_mode="random_n", - env_selection_random_count=3, - env_selection_random_seed=0, - env_selection_max_visible=1, - ) + cfg = _make_cfg(visible_env_indices=[0, 2, 4], max_visible_envs=1) viz = _DummyVisualizer(cfg) viz._scene_data_provider = _FakeProvider(num_envs=10) ids = viz._compute_visualized_env_ids() - assert ids is not None and len(ids) == 3 - assert resolve_visible_env_indices(ids, cfg.env_selection_max_visible, 10) == list(ids) + assert ids == [0, 2, 4] + assert resolve_visible_env_indices(ids, cfg.max_visible_envs, 10) == [0] def test_resolve_camera_pose_from_usd_path_uses_provider_transforms(): diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index 19b45dbf426b..c93ac554fe68 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -212,7 +212,7 @@ def launch_simulation( close_fn = app_launcher.app.close elif visualizer_types: # Newton path without Kit: AppLauncher is skipped — persist the same visualizer CLI - # settings (types, viz_env_selection_max_visible) that AppLauncher would write. + # settings (types, max_visible_envs CLI override) that AppLauncher would write. from isaaclab.app.app_launcher import sync_visualizer_cli_settings_to_carb if isinstance(launcher_args, argparse.Namespace): diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 0c7f15e62ee2..6e3fd2c69037 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -77,7 +77,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._env_ids = self._compute_visualized_env_ids() num_envs_meta = int(metadata.get("num_envs", 0)) self._resolved_visible_env_ids = resolve_visible_env_indices( - self._env_ids, self.cfg.env_selection_max_visible, num_envs_meta + self._env_ids, self.cfg.max_visible_envs, num_envs_meta ) if self._resolved_visible_env_ids is not None: logger.warning( @@ -94,7 +94,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: ("eye", self.cfg.eye), ("lookat", self.cfg.lookat), ("cam_source", self.cfg.cam_source), - ("env_selection_max_visible", self.cfg.env_selection_max_visible), + ("max_visible_envs", self.cfg.max_visible_envs), ("num_visualized_envs", num_visualized_envs), ("create_viewport", self.cfg.create_viewport), ("headless", self._runtime_headless), diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 3080ebe691e4..f39681feabdf 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -16,7 +16,7 @@ from isaaclab.visualizers.base_visualizer import BaseVisualizer -from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds +from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices from .newton_visualizer_cfg import NewtonVisualizerCfg @@ -309,7 +309,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: apply_viewer_visible_worlds( self._viewer, env_ids=self._env_ids, - env_selection_max_visible=self.cfg.env_selection_max_visible, + max_visible_envs=self.cfg.max_visible_envs, num_envs=num_envs, ) self._viewer.set_world_offsets((0.0, 0.0, 0.0)) @@ -336,7 +336,8 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._viewer.renderer.sky_lower = self._viewer._coerce_color3(self.cfg.sky_lower_color) self._viewer.renderer._light_color = self._viewer._coerce_color3(self.cfg.light_color) - num_visualized_envs = len(self._env_ids) if self._env_ids is not None else int(metadata.get("num_envs", 0)) + _resolved = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) + num_visualized_envs = len(_resolved) if _resolved is not None else num_envs self._log_initialization_table( logger=logger, title="NewtonVisualizer Configuration", diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py index d9a39be376fa..38b6901099b7 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py @@ -10,27 +10,45 @@ def resolve_visible_env_indices( env_ids: list[int] | None, - env_selection_max_visible: int | None, + max_visible_envs: int | None, num_envs: int, ) -> list[int] | None: """Resolve which env indices stay visible (same rules as :func:`apply_viewer_visible_worlds`). + * Cap-only path (``env_ids`` is ``None``): contiguous ``0 .. min(cap, num_envs) - 1`` when ``max_visible_envs`` + is set; otherwise ``None`` (viewer shows all worlds). + * Explicit path (``env_ids`` is a list): if ``max_visible_envs`` is set, keep only the first *cap* indices + (truncate from the end); if ``None``, use the full list. + Returns: - Selected indices, or ``None`` when all environments should be visible. + Selected indices, or ``None`` when all environments should be visible (cap-only, no limit). """ if env_ids is not None: - return list(env_ids) - if env_selection_max_visible is not None and num_envs > 0: - n = min(int(env_selection_max_visible), num_envs) + out = list(env_ids) + if max_visible_envs is not None: + out = out[: max(0, int(max_visible_envs))] + return out + if max_visible_envs is not None and num_envs > 0: + n = min(int(max_visible_envs), num_envs) return list(range(n)) return None + cap = max(0, int(max_visible_envs)) + if cap == 0: + return [] + + if num_envs > 0: + return list(range(min(cap, num_envs))) + + # num_envs not reported yet (e.g. env prims not discovered); still cap so we do not return None below. + return list(range(cap)) + def apply_viewer_visible_worlds( viewer, *, env_ids: list[int] | None, - env_selection_max_visible: int | None, + max_visible_envs: int | None, num_envs: int, ) -> None: """Select which simulation worlds are visualized; no-op if the viewer does not support it. @@ -39,15 +57,15 @@ def apply_viewer_visible_worlds( Args: viewer: Newton viewer (ViewerGL, ViewerRerun, ViewerViser, etc.). - env_ids: Explicit env indices from ``env_selection_*`` config, or ``None`` when showing all - unless ``env_selection_max_visible`` limits the count (see ``VisualizerCfg``). - env_selection_max_visible: Optional cap on the number of worlds (``0..num_envs-1``) when ``env_ids`` is - ``None``. + env_ids: Env indices from ``visible_env_indices`` (after validation), or ``None`` for the cap-only + contiguous path (see ``VisualizerCfg``). + max_visible_envs: When ``env_ids`` is ``None``, caps the contiguous count; otherwise truncates the list to + the first *N* indices. num_envs: Total environment count from scene metadata. """ if not hasattr(viewer, "set_visible_worlds"): return - resolved = resolve_visible_env_indices(env_ids, env_selection_max_visible, num_envs) + resolved = resolve_visible_env_indices(env_ids, max_visible_envs, num_envs) if resolved is None: viewer.set_visible_worlds(None) else: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py index a322f00f3552..0944abe11ad2 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py @@ -20,7 +20,7 @@ from isaaclab.visualizers.base_visualizer import BaseVisualizer -from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds +from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices from .rerun_visualizer_cfg import RerunVisualizerCfg @@ -185,7 +185,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: apply_viewer_visible_worlds( self._viewer, env_ids=self._env_ids, - env_selection_max_visible=self.cfg.env_selection_max_visible, + max_visible_envs=self.cfg.max_visible_envs, num_envs=num_envs, ) # Preserve simulation world positions (env_spacing) rather than adding viewer-side offsets. @@ -196,7 +196,8 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._viewer.scaling = 1.0 self._viewer._paused = False - num_visualized_envs = len(self._env_ids) if self._env_ids is not None else int(metadata.get("num_envs", 0)) + _resolved = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs) + num_visualized_envs = len(_resolved) if _resolved is not None else num_envs self._log_initialization_table( logger=logger, title="RerunVisualizer Configuration", diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py index 6ae70705d109..3d801c9f330f 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py @@ -19,7 +19,7 @@ from isaaclab.visualizers.base_visualizer import BaseVisualizer -from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds +from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices from .viser_visualizer_cfg import ViserVisualizerCfg @@ -150,7 +150,9 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._active_record_path = self.cfg.record_to_viser self._create_viewer(record_to_viser=self.cfg.record_to_viser, metadata=metadata) - num_visualized_envs = len(self._env_ids) if self._env_ids is not None else int(metadata.get("num_envs", 0)) + num_envs_meta = int(metadata.get("num_envs", 0)) + _resolved = resolve_visible_env_indices(self._env_ids, self.cfg.max_visible_envs, num_envs_meta) + num_visualized_envs = len(_resolved) if _resolved is not None else num_envs_meta viewer_url = _viser_web_viewer_url(self.cfg.port) self._log_initialization_table( logger=logger, @@ -252,7 +254,7 @@ def _create_viewer(self, record_to_viser: str | None, metadata: dict | None = No apply_viewer_visible_worlds( self._viewer, env_ids=self._env_ids, - env_selection_max_visible=self.cfg.env_selection_max_visible, + max_visible_envs=self.cfg.max_visible_envs, num_envs=num_envs, ) # Preserve simulation world positions (env_spacing) rather than adding viewer-side offsets. diff --git a/source/isaaclab_visualizers/test/test_newton_adapter.py b/source/isaaclab_visualizers/test/test_newton_adapter.py index 5dda43f542a0..3c020a8d10ee 100644 --- a/source/isaaclab_visualizers/test/test_newton_adapter.py +++ b/source/isaaclab_visualizers/test/test_newton_adapter.py @@ -10,12 +10,17 @@ from isaaclab_visualizers.newton_adapter import apply_viewer_visible_worlds, resolve_visible_env_indices -def test_resolve_visible_env_indices_env_ids_win(): - assert resolve_visible_env_indices([1, 3], 1, 10) == [1, 3] +def test_resolve_visible_env_indices_truncates_explicit_list(): + assert resolve_visible_env_indices([1, 3, 5], 2, 10) == [1, 3] + assert resolve_visible_env_indices([1, 3], 1, 10) == [1] + + +def test_resolve_visible_env_indices_explicit_full_list_when_no_cap(): + assert resolve_visible_env_indices([1, 3], None, 10) == [1, 3] def test_resolve_visible_env_indices_cap_when_no_filter(): - # When _compute_visualized_env_ids is None (e.g. mode ``none``), cap is env_selection_max_visible, not random_count. + # When _compute_visualized_env_ids is None, cap is max_visible_envs. assert resolve_visible_env_indices(None, 3, 10) == [0, 1, 2] @@ -34,11 +39,11 @@ class _V: def set_visible_worlds(self, worlds): calls.append(worlds) - apply_viewer_visible_worlds(_V(), env_ids=None, env_selection_max_visible=2, num_envs=5) + apply_viewer_visible_worlds(_V(), env_ids=None, max_visible_envs=2, num_envs=5) assert calls == [[0, 1]] - apply_viewer_visible_worlds(_V(), env_ids=[2], env_selection_max_visible=99, num_envs=5) + apply_viewer_visible_worlds(_V(), env_ids=[2], max_visible_envs=99, num_envs=5) assert calls[-1] == [2] - apply_viewer_visible_worlds(_V(), env_ids=None, env_selection_max_visible=None, num_envs=3) + apply_viewer_visible_worlds(_V(), env_ids=None, max_visible_envs=None, num_envs=3) assert calls[-1] is None From 8c2bd19ecd890f90a3db854bec538f1b29a51b09 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Tue, 21 Apr 2026 00:55:40 +0000 Subject: [PATCH 08/37] simplify --- docs/source/features/visualization.rst | 16 ++-- source/isaaclab/isaaclab/app/app_launcher.py | 86 +++++-------------- .../isaaclab/sim/simulation_context.py | 52 +++-------- source/isaaclab/test/app/test_kwarg_launch.py | 1 - .../isaaclab_tasks/utils/sim_launcher.py | 9 +- 5 files changed, 42 insertions(+), 122 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 7f4754d2ffc6..ad250b516575 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -144,20 +144,14 @@ Partial visualization Partial visualization can be used to visualize a subset of envs to improve performance. -``max_visible_envs`` limits how many envs are shown. If ``visible_env_indices`` is ``None``, it uses contiguous -indices ``0 .. min(cap, num_envs) - 1``. If ``visible_env_indices`` is set, valid indices are kept in order, then the -list is **truncated from the end** if it has more than *cap* entries. Set ``max_visible_envs`` to ``None`` for no cap -on that side (full contiguous range, or the full explicit list). -``visible_env_indices`` lists env indices in preference order. Set to ``None`` to use only the contiguous cap above. +There are 2 fields exposed in the VisualizerCfg for specifying the envs to visualize: +- ``max_visible_envs`` caps how many envs are shown. +- ``visible_env_indices`` explicitly selects the envs to visualize. -**CLI vs cap:** The only related CLI flag is ``--max_visible_envs``. It overrides ``VisualizerCfg.max_visible_envs`` -for the run and therefore applies both to the contiguous case and as the truncation length for explicit index lists. -``visible_env_indices`` is config-only (not a CLI flag). +Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. -- **Newton, Rerun, Viser:** Newton ``set_visible_worlds`` limits which worlds the viewer draws. -- **Kit (Omniverse):** Non-selected ``/World/envs/env_*`` prims are hidden via USD visibility. **This is not a reliable - performance optimization** in Kit today; it is primarily cosmetic. +Note, currently the KitVisualizer just sets the non selected envs to invisible, which doesn't improve performance much. .. _visualization-common-modes: diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index cc5a03dc56af..eb0762712a11 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -34,20 +34,14 @@ logger = logging.getLogger(__name__) -def sync_visualizer_cli_settings_to_carb( - launcher_args: dict, - *, - cli_explicit: bool | None = None, - cli_disable_all: bool | None = None, -) -> None: - """Persist visualizer CLI flags (selection, ``--max_visible_envs``) to carb settings. - - Optional arguments use :data:`argparse.SUPPRESS` defaults so only options the user actually passed - appear in *launcher_args*. We record ``cli_override/max_visible_envs`` and - ``/isaaclab/visualizer/max_visible_envs`` for :meth:`SimulationContext._apply_visualizer_cli_overrides`. - - Used by :class:`AppLauncher` and by standalone Newton/Rerun/Viser flows that skip Kit - (see :mod:`isaaclab_tasks.utils.sim_launcher`). +def sync_visualizer_cli_settings_to_carb(launcher_args: dict) -> None: + """Write visualizer CLI selection and ``--max_visible_envs`` to carb settings. + + Callers may set ``visualizer_explicit`` / ``visualizer_disable_all`` when those values + were resolved elsewhere (e.g. :class:`AppLauncher` strips flags from *launcher_args*). + Otherwise ``disable_all`` is inferred from ``"none"`` in ``visualizer``. + + Also used when Kit is skipped (see :mod:`isaaclab_tasks.utils.sim_launcher`). """ visualizers = launcher_args.get("visualizer") @@ -56,9 +50,10 @@ def sync_visualizer_cli_settings_to_carb( if v is not None and int(v) < 0: raise ValueError(f"Invalid value for --max_visible_envs: {v}. Expected non-negative int.") - if cli_explicit is None: - cli_explicit = bool(launcher_args.get("visualizer_explicit", False)) - if cli_disable_all is None: + cli_explicit = bool(launcher_args.get("visualizer_explicit", False)) + if "visualizer_disable_all" in launcher_args: + cli_disable_all = bool(launcher_args["visualizer_disable_all"]) + else: cli_disable_all = bool(cli_explicit) and visualizers is not None and "none" in visualizers with contextlib.suppress(Exception): @@ -68,15 +63,9 @@ def sync_visualizer_cli_settings_to_carb( settings.set_bool("/isaaclab/visualizer/explicit", cli_explicit) settings.set_bool("/isaaclab/visualizer/disable_all", cli_disable_all) - settings.set_bool( - "/isaaclab/visualizer/cli_override/max_visible_envs", - "max_visible_envs" in launcher_args, - ) + # Sentinel: ``-1`` means ``--max_visible_envs`` was not passed (see ``SimulationContext``). if "max_visible_envs" in launcher_args: - settings.set_int( - "/isaaclab/visualizer/max_visible_envs", - int(launcher_args["max_visible_envs"]), - ) + settings.set_int("/isaaclab/visualizer/max_visible_envs", int(launcher_args["max_visible_envs"])) else: settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) @@ -1197,50 +1186,15 @@ def _set_animation_recording_settings(self, launcher_args: dict) -> None: settings.set_float("/isaaclab/anim_recording/start_time", start_time) settings.set_float("/isaaclab/anim_recording/stop_time", stop_time) - def _warn_if_max_visible_envs_unused(self, launcher_args: dict) -> None: - """Log when ``--max_visible_envs`` cannot affect any running visualizer for this process.""" - if "max_visible_envs" not in launcher_args: - return - - disable_all = getattr(self, "_cli_visualizer_disable_all", False) - explicit = getattr(self, "_cli_visualizer_explicit", False) - cfg_has_any = getattr(self, "_cfg_has_any_visualizers", False) - cli_types = getattr(self, "_cli_visualizer_types", []) - - if disable_all: - logger.warning( - "[AppLauncher] --max_visible_envs was set but all visualizers are disabled " - "(for example ``--viz none`` or deprecated ``--headless`` with ``--viz``); " - "the value is not applied." - ) - return - - if explicit: - if cli_types: - return - logger.warning( - "[AppLauncher] --max_visible_envs was set but no visualizers are selected on the CLI; " - "the value is not applied unless ``SimulationCfg.visualizer_cfgs`` configures visualizers." - ) - return - - if cfg_has_any: - return - - logger.warning( - "[AppLauncher] --max_visible_envs was set but no visualizers are configured for this run " - "(pass ``--viz `` and/or set ``SimulationCfg.visualizer_cfgs``); " - "the value is not applied." - ) - def _set_visualizer_settings(self, launcher_args: dict) -> None: - """Store visualizer selection and Newton viewer CLI overrides in settings.""" + """Persist visualizer CLI flags and ``max_visible_envs`` override for :class:`SimulationContext`.""" sync_visualizer_cli_settings_to_carb( - launcher_args, - cli_explicit=getattr(self, "_cli_visualizer_explicit", False), - cli_disable_all=getattr(self, "_cli_visualizer_disable_all", False), + { + **launcher_args, + "visualizer_explicit": getattr(self, "_cli_visualizer_explicit", False), + "visualizer_disable_all": getattr(self, "_cli_visualizer_disable_all", False), + } ) - self._warn_if_max_visible_envs_unused(launcher_args) def _interrupt_signal_handle_callback(self, signal, frame): """Handle the interrupt signal from the keyboard.""" diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 358c0d1b4088..9a43056430cb 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -430,47 +430,23 @@ def _get_cli_visualizer_types(self) -> list[str]: # App launcher writes this as a single string; accept comma and/or whitespace separators. return [value for chunk in requested.split(",") for value in chunk.split() if value] - def _cli_visualizer_field_overridden(self, field: str) -> bool: - """Return True when the user passed the matching CLI flag (see ``cli_override/*`` settings).""" - v = self.get_setting(f"/isaaclab/visualizer/cli_override/{field}") - if v is not None: - return bool(v) - # Legacy: before cli_override existed, a non-negative max_visible_envs int implied CLI intent. - if field == "max_visible_envs": - raw = self.get_setting("/isaaclab/visualizer/max_visible_envs") - if raw is None: - return False - try: - return int(raw) >= 0 - except (TypeError, ValueError): - return False - return False - - def _get_cli_max_visible_envs_override(self) -> tuple[bool, int | None]: - """CLI override for ``max_visible_envs`` when ``--max_visible_envs`` is set.""" - if not self._cli_visualizer_field_overridden("max_visible_envs"): - return False, None - value = self.get_setting("/isaaclab/visualizer/max_visible_envs") - if value is None: - return False, None + def _apply_visualizer_cli_overrides(self, visualizer_cfgs: list[Any]) -> None: + """Apply ``--max_visible_envs`` to every resolved visualizer cfg when set in settings. + + AppLauncher stores ``/isaaclab/visualizer/max_visible_envs`` as ``-1`` when the flag was + omitted; any non-negative int overrides :attr:`VisualizerCfg.max_visible_envs` on each cfg. + """ + raw = self.get_setting("/isaaclab/visualizer/max_visible_envs") try: - max_visible = int(value) + max_visible = int(raw) if raw is not None else -1 except (TypeError, ValueError): - logger.warning( - "[SimulationContext] Invalid /isaaclab/visualizer/max_visible_envs setting: %r", value - ) - return False, None + logger.warning("[SimulationContext] Invalid /isaaclab/visualizer/max_visible_envs: %r", raw) + return if max_visible < 0: - return False, None - return True, max_visible - - def _apply_visualizer_cli_overrides(self, visualizer_cfgs: list[Any]) -> None: - """Apply CLI visualizer overrides to resolved configs (only fields the user set on the CLI).""" - has_max, max_visible_override = self._get_cli_max_visible_envs_override() - if has_max: - for cfg in visualizer_cfgs: - if hasattr(cfg, "max_visible_envs"): - cfg.max_visible_envs = max_visible_override + return + for cfg in visualizer_cfgs: + if hasattr(cfg, "max_visible_envs"): + cfg.max_visible_envs = max_visible def _is_cli_visualizer_explicit(self) -> bool: """Return ``True`` when visualizers were explicitly provided via CLI.""" diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index dbacba8adfa1..ca64fc747b16 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -49,7 +49,6 @@ def test_set_visualizer_settings_stores_values(monkeypatch: pytest.MonkeyPatch): "/isaaclab/visualizer/types": "viser rerun", "/isaaclab/visualizer/explicit": False, "/isaaclab/visualizer/disable_all": False, - "/isaaclab/visualizer/cli_override/max_visible_envs": True, "/isaaclab/visualizer/max_visible_envs": 0, } diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index c93ac554fe68..3e3661b90c95 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -215,17 +215,14 @@ def launch_simulation( # settings (types, max_visible_envs CLI override) that AppLauncher would write. from isaaclab.app.app_launcher import sync_visualizer_cli_settings_to_carb + disable_all = "none" in visualizer_types if isinstance(launcher_args, argparse.Namespace): sync_visualizer_cli_settings_to_carb( - vars(launcher_args), - cli_explicit=True, - cli_disable_all=("none" in visualizer_types), + {**vars(launcher_args), "visualizer_explicit": True, "visualizer_disable_all": disable_all} ) elif isinstance(launcher_args, dict): sync_visualizer_cli_settings_to_carb( - launcher_args, - cli_explicit=True, - cli_disable_all=("none" in visualizer_types), + {**launcher_args, "visualizer_explicit": True, "visualizer_disable_all": disable_all} ) try: From f057f8f8af8fc0ef64b369897c8627a9e6a3ce8f Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Tue, 21 Apr 2026 01:10:31 +0000 Subject: [PATCH 09/37] working --- source/isaaclab/isaaclab/app/app_launcher.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index eb0762712a11..4604ca788821 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -524,8 +524,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: type=int, default=argparse.SUPPRESS, help=( - "When set, overrides ``max_visible_envs``: contiguous count when ``visible_env_indices`` is unset, " - "or max length of an explicit index list (truncates from the end). If omitted, config values apply." + "When set, caps the nums of envs shown in the launched visualizers to improve performance." ), ) # special flag for backwards compatibility From f385837e0896b0dd72cfe5c5f14d928a132423a9 Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Tue, 21 Apr 2026 11:18:07 -0700 Subject: [PATCH 10/37] Update visualization.rst Signed-off-by: matthewtrepte --- docs/source/features/visualization.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index ad250b516575..db1b8271267a 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -139,19 +139,21 @@ The effective visualizer mode is resolved from both CLI and ``SimulationCfg.visu For the migration-focused summary and deprecation context, see :doc:`/source/migration/migrating_to_isaaclab_3-0`. -Partial visualization +Partial Visualization ~~~~~~~~~~~~~~~~~~~~~ -Partial visualization can be used to visualize a subset of envs to improve performance. +Visualizers can be configured to visualize just a subset of environments to improve performance. +This is called partial visualization. - -There are 2 fields exposed in the VisualizerCfg for specifying the envs to visualize: +There are 2 fields exposed in the VisualizerCfg for selecting environments for partial visualization: - ``max_visible_envs`` caps how many envs are shown. - ``visible_env_indices`` explicitly selects the envs to visualize. +- ``randomly_sample_visible_envs`` enables unifom sampling of the selected envs, if ``visible_env_indices`` is not provided. Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. -Note, currently the KitVisualizer just sets the non selected envs to invisible, which doesn't improve performance much. +Note, in the current release, the KitVisualizer does not fully support partial visualization. The non-selected environments +are made invisible which does not improve performance much. .. _visualization-common-modes: From d39fdaf5b2e32c1b0683199bcbbfb5c3dd4c8842 Mon Sep 17 00:00:00 2001 From: matthewtrepte Date: Tue, 21 Apr 2026 11:20:00 -0700 Subject: [PATCH 11/37] Update visualization.rst Signed-off-by: matthewtrepte --- docs/source/features/visualization.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index db1b8271267a..a48ab137d699 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -142,7 +142,7 @@ For the migration-focused summary and deprecation context, see Partial Visualization ~~~~~~~~~~~~~~~~~~~~~ -Visualizers can be configured to visualize just a subset of environments to improve performance. +To improve performance, visualizers can be configured to visualize just a subset of environments. This is called partial visualization. There are 2 fields exposed in the VisualizerCfg for selecting environments for partial visualization: From 794146635a55e21b5ff254dcf9828f4a3d5adebc Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Tue, 21 Apr 2026 18:35:49 +0000 Subject: [PATCH 12/37] docs --- docs/source/features/visualization.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index a48ab137d699..4cececae144c 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -145,10 +145,10 @@ Partial Visualization To improve performance, visualizers can be configured to visualize just a subset of environments. This is called partial visualization. -There are 2 fields exposed in the VisualizerCfg for selecting environments for partial visualization: +There are 2 fields exposed in the ``VisualizerCfg`` for selecting environments for partial visualization: + - ``max_visible_envs`` caps how many envs are shown. - ``visible_env_indices`` explicitly selects the envs to visualize. -- ``randomly_sample_visible_envs`` enables unifom sampling of the selected envs, if ``visible_env_indices`` is not provided. Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. From 47795f123dec364a617c9c61d354ccf90c34a7e3 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Tue, 21 Apr 2026 21:45:21 +0000 Subject: [PATCH 13/37] clean --- docs/source/features/visualization.rst | 8 ++++++- source/isaaclab/isaaclab/envs/common.py | 15 ++++-------- .../isaaclab/visualizers/base_visualizer.py | 13 ++++++++++ .../isaaclab/visualizers/visualizer_cfg.py | 8 ++++++- .../test_simulation_context_visualizers.py | 12 ++++++++-- .../test/visualizers/test_visualizer.py | 24 +++++++++++++++++++ .../isaaclab_visualizers/newton_adapter.py | 13 ++-------- .../test_visualizer_cartpole_integration.py | 19 ++++++++++++--- 8 files changed, 83 insertions(+), 29 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 4cececae144c..9c04bc9c9e4f 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -145,10 +145,16 @@ Partial Visualization To improve performance, visualizers can be configured to visualize just a subset of environments. This is called partial visualization. -There are 2 fields exposed in the ``VisualizerCfg`` for selecting environments for partial visualization: +There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments for partial visualization: - ``max_visible_envs`` caps how many envs are shown. - ``visible_env_indices`` explicitly selects the envs to visualize. +- ``randomly_sample_visible_envs`` (default ``True``): when ``visible_env_indices`` is unset and ``max_visible_envs`` is set, + pick that many env indices uniformly at random once at init (sorted). + +.. note:: + ``max_visible_envs=None`` means no cap (every environment); random sampling does not run in that case. + The field default on ``VisualizerCfg`` is ``4``, not ``None``—override to ``None`` explicitly if you want all envs. Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. diff --git a/source/isaaclab/isaaclab/envs/common.py b/source/isaaclab/isaaclab/envs/common.py index 033b9c38610f..5da6f871361e 100644 --- a/source/isaaclab/isaaclab/envs/common.py +++ b/source/isaaclab/isaaclab/envs/common.py @@ -35,11 +35,8 @@ class ViewerCfg: """Configuration of the scene viewport camera. Note: - Overriding non-default fields is deprecated. In a future release, Isaac Sim viewport camera - configuration will be expressed only through ``KitVisualizerCfg`` under - ``SimulationCfg.visualizer_cfgs``; use ``NewtonVisualizerCfg`` for the Newton viewer. - Those visualizer configs replace the viewport camera pose, resolution, prim path, and - frame-origin behavior that this class used to configure. + ViewerCfg is deprecated. In a future release, this config will be streamlined with + the KitVisualizerCfg. """ eye: tuple[float, float, float] = (7.5, 7.5, 7.5) @@ -107,12 +104,8 @@ def __post_init__(self) -> None: differing.append(f.name) if differing: warnings.warn( - "ViewerCfg is deprecated when overriding default viewport camera fields " - f"({', '.join(sorted(differing))}). In a future release, Isaac Sim viewport camera " - "settings will be configured only through ``SimulationCfg.visualizer_cfgs`` using " - "``KitVisualizerCfg`` (viewport camera pose, resolution, prim path, and " - "frame-origin options). For the Newton viewer, use ``NewtonVisualizerCfg``. " - "Migrate overrides out of ``ViewerCfg`` accordingly.", + "ViewerCfg is deprecated. In a future release, this config will be streamlined with " + "the KitVisualizerCfg.", DeprecationWarning, stacklevel=2, ) diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index c4a75420a472..e5a5baa20d18 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +import random import re from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any @@ -150,8 +151,20 @@ def _compute_visualized_env_ids(self) -> list[int] | None: if num_envs <= 0: logger.debug("[Visualizer] num_envs is 0 or missing from provider metadata; env selection disabled.") return None + # Explicit list wins; never combine with random cap-only mode. if cfg.visible_env_indices is not None: return [i for i in cfg.visible_env_indices if 0 <= i < num_envs] + + max_visible = getattr(cfg, "max_visible_envs", None) + # Random subset only for cap-only mode: needs a cap and no explicit indices (see VisualizerCfg). + if ( + max_visible is not None + and getattr(cfg, "randomly_sample_visible_envs", True) + and int(max_visible) >= 0 + ): + k = min(int(max_visible), num_envs) + # k == 0: sample(range(n), 0) is []; contiguous resolver used the same convention. + return sorted(random.sample(range(num_envs), k)) return None def get_rendering_dt(self) -> float | None: diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 2b3309b0a8d7..f9504ee43afc 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -46,7 +46,7 @@ class VisualizerCfg: cam_prim_path: str = "/World/envs/env_0/Camera" """Absolute USD path to a camera prim when cam_source='prim_path'.""" - max_visible_envs: int | None = 4 + max_visible_envs: int | None = None """Upper bound on how many envs are shown. * If visible_env_indices is not None, then this field will apply also @@ -56,6 +56,12 @@ class VisualizerCfg: visible_env_indices: list[int] | None = None """env indices to visualize in order (out-of-range indices are dropped).""" + randomly_sample_visible_envs: bool = True + """If ``max_visible_envs`` is provided, the selected visible envs are randomly sampled. + + * Note ``visible_env_indices`` overrides this field. + """ + def get_visualizer_type(self) -> str | None: """Get the visualizer type identifier. diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index 1f9a794ef1d9..d3f1d31c289e 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -275,7 +275,11 @@ def set_world_offsets(self, spacing) -> None: ) monkeypatch.setattr(viser_visualizer.ViserVisualizer, "_set_viser_camera_view", lambda self, pose: None) - cfg = ViserVisualizerCfg(max_visible_envs=cfg_max_visible_envs, open_browser=False) + cfg = ViserVisualizerCfg( + max_visible_envs=cfg_max_visible_envs, + open_browser=False, + randomly_sample_visible_envs=False, + ) visualizer = viser_visualizer.ViserVisualizer(cfg) visualizer._model = "dummy-model" visualizer._env_ids = None # normally set by initialize() -> _compute_visualized_env_ids() @@ -359,7 +363,11 @@ def get_newton_state(self, env_ids: list[int] | None): ) monkeypatch.setattr(rerun_visualizer.RerunVisualizer, "_apply_camera_pose", lambda self, pose: None) - cfg = RerunVisualizerCfg(open_browser=False, max_visible_envs=cfg_max_visible_envs) + cfg = RerunVisualizerCfg( + open_browser=False, + max_visible_envs=cfg_max_visible_envs, + randomly_sample_visible_envs=False, + ) visualizer = rerun_visualizer.RerunVisualizer(cfg) visualizer.initialize(cast(Any, _DummyRerunSceneDataProvider())) diff --git a/source/isaaclab/test/visualizers/test_visualizer.py b/source/isaaclab/test/visualizers/test_visualizer.py index 96b4b4fb46af..44d3a89aea16 100644 --- a/source/isaaclab/test/visualizers/test_visualizer.py +++ b/source/isaaclab/test/visualizers/test_visualizer.py @@ -64,6 +64,8 @@ def _make_cfg(**kwargs): cfg = { "max_visible_envs": None, "visible_env_indices": None, + # Default off in tests: contiguous cap-only path matches historical assertions. + "randomly_sample_visible_envs": False, } cfg.update(kwargs) return SimpleNamespace(**cfg) @@ -113,6 +115,28 @@ def test_partial_visualization_cap_only_uses_resolver(): assert resolve_visible_env_indices(None, 3, 10) == [0, 1, 2] +@pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") +def test_compute_visualized_env_ids_random_cap_only_sorted_once(): + """Cap-only random mode returns a sorted sample; explicit indices ignore the flag.""" + cfg = _make_cfg(max_visible_envs=3, visible_env_indices=None, randomly_sample_visible_envs=True) + viz = _DummyVisualizer(cfg) + viz._scene_data_provider = _FakeProvider(num_envs=10) + sampled = viz._compute_visualized_env_ids() + assert sampled is not None and len(sampled) == 3 + assert sampled == sorted(sampled) + assert len(set(sampled)) == 3 + assert all(0 <= i < 10 for i in sampled) + + cfg_explicit = _make_cfg( + visible_env_indices=[1, 5], + max_visible_envs=1, + randomly_sample_visible_envs=True, + ) + viz2 = _DummyVisualizer(cfg_explicit) + viz2._scene_data_provider = _FakeProvider(num_envs=10) + assert viz2._compute_visualized_env_ids() == [1, 5] + + @pytest.mark.skipif(not _HAS_ISAACLAB_VIZ, reason="isaaclab_visualizers not installed") def test_explicit_visible_env_indices_truncated_by_max_visible_envs(): """Explicit indices from :meth:`_compute_visualized_env_ids`; ``max_visible_envs`` truncates from the end.""" diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py index 38b6901099b7..6bc3d5a2b4f1 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton_adapter.py @@ -16,7 +16,8 @@ def resolve_visible_env_indices( """Resolve which env indices stay visible (same rules as :func:`apply_viewer_visible_worlds`). * Cap-only path (``env_ids`` is ``None``): contiguous ``0 .. min(cap, num_envs) - 1`` when ``max_visible_envs`` - is set; otherwise ``None`` (viewer shows all worlds). + is set; otherwise ``None`` (viewer shows all worlds). (Random cap-only selection is applied earlier by + turning it into explicit ``env_ids``.) * Explicit path (``env_ids`` is a list): if ``max_visible_envs`` is set, keep only the first *cap* indices (truncate from the end); if ``None``, use the full list. @@ -33,16 +34,6 @@ def resolve_visible_env_indices( return list(range(n)) return None - cap = max(0, int(max_visible_envs)) - if cap == 0: - return [] - - if num_envs > 0: - return list(range(min(cap, num_envs))) - - # num_envs not reported yet (e.g. env prims not discovered); still cap so we do not return None below. - return list(range(cap)) - def apply_viewer_visible_worlds( viewer, diff --git a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py index 9b7244903425..eb5f7149432e 100644 --- a/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py +++ b/source/isaaclab_visualizers/test/test_visualizer_cartpole_integration.py @@ -165,12 +165,24 @@ def _get_visualizer_cfg(visualizer_kind: str): if visualizer_kind == "newton": __import__("newton") nw, nh = _CARTPOLE_NEWTON_INTEGRATION_WINDOW_SIZE - return NewtonVisualizerCfg(headless=True, window_width=nw, window_height=nh, **cam), NewtonVisualizer + return ( + NewtonVisualizerCfg( + headless=True, + window_width=nw, + window_height=nh, + randomly_sample_visible_envs=False, + **cam, + ), + NewtonVisualizer, + ) if visualizer_kind == "viser": __import__("newton") __import__("viser") port = _find_free_tcp_port(host="127.0.0.1") - return ViserVisualizerCfg(open_browser=False, port=port, **cam), ViserVisualizer + return ( + ViserVisualizerCfg(open_browser=False, port=port, randomly_sample_visible_envs=False, **cam), + ViserVisualizer, + ) if visualizer_kind == "rerun": __import__("newton") __import__("rerun") @@ -181,11 +193,12 @@ def _get_visualizer_cfg(visualizer_kind: str): open_browser=False, web_port=web_port, grpc_port=grpc_port, + randomly_sample_visible_envs=False, **cam, ), RerunVisualizer, ) - return KitVisualizerCfg(**cam), KitVisualizer + return KitVisualizerCfg(randomly_sample_visible_envs=False, **cam), KitVisualizer def _get_physics_cfg(backend_kind: str): From 30931aea2a3096558fac8d1cf2ac57f225b88bc4 Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Wed, 22 Apr 2026 06:17:10 +0800 Subject: [PATCH 14/37] Upgrade newton (#5339) # Description This PR upgrades the Newton physics library to git commit `a27277`, pins `mujoco` and `mujoco-warp` to 3.6.0, and refreshes four Dexsuite Kuka golden images that changed because the new Newton version now correctly honors prim visibility in the Warp renderer. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 9 +++++++++ source/isaaclab/setup.py | 4 ++-- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 12 ++++++++++++ source/isaaclab_newton/setup.py | 6 +++--- source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 9 +++++++++ .../newton-newton_renderer-rgb.png | Bin 6020 -> 5780 bytes .../newton-newton_renderer-rgba.png | Bin 6628 -> 6271 bytes .../physx-newton_renderer-rgb.png | Bin 5798 -> 5501 bytes .../physx-newton_renderer-rgba.png | Bin 6379 -> 5994 bytes source/isaaclab_visualizers/setup.py | 6 +++--- tools/wheel_builder/res/python_packages.toml | 6 +++--- 14 files changed, 44 insertions(+), 14 deletions(-) diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 85b9e265b1b7..35664f87df0a 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.7" +version = "4.6.8" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index a6f25d38ee40..4f945e95a27a 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +4.6.8 (2026-04-21) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Pinned ``mujoco`` and ``mujoco-warp`` to ``3.6.0`` to align with the Newton library. + + 4.6.7 (2026-04-20) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/setup.py b/source/isaaclab/setup.py index db3d42f1f279..7d14504f48a3 100644 --- a/source/isaaclab/setup.py +++ b/source/isaaclab/setup.py @@ -30,8 +30,8 @@ # procedural-generation "trimesh", "pyglet>=2.1.6,<3", - "mujoco==3.5.0", - "mujoco-warp==3.5.0.2", + "mujoco==3.6.0", + "mujoco-warp==3.6.0", # image processing "transformers==4.57.6", "einops", # needed for transformers, doesn't always auto-install diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index fe15054f69cd..810d1e6174f3 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.17" +version = "0.5.18" # Description title = "Newton simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 0230a394935e..93f40d2a148e 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.5.18 (2026-04-21) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Upgraded Newton from ``2684d75`` to ``a27277e``. Includes collision improvements, contact quality fixes, + hydroelastic contact optimization, and memory usage fixes in CollisionPipeline. For details see + ``Newton changelog ``. +* Pinned ``mujoco`` and ``mujoco-warp`` to ``3.6.0`` to align with the Newton library. + + 0.5.17 (2026-04-20) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index 421cecd502ca..c83dd352710a 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -42,10 +42,10 @@ def run(self): EXTRAS_REQUIRE = { "all": [ "prettytable==3.3.0", - "mujoco==3.5.0", - "mujoco-warp==3.5.0.2", + "mujoco==3.6.0", + "mujoco-warp==3.6.0", "PyOpenGL-accelerate==3.1.10", - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", ], } diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index 1a579ed0ef48..bc0640c4cbd6 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "1.5.22" +version = "1.5.23" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index 3c9e06860e45..b0cf36c9369b 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog --------- +1.5.23 (2026-04-21) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Refreshed Newton Warp renderer golden images for Dexsuite Kuka-Allegro environment case in + ``test_rendering_correctness`` because Newton Warp renderer honors visibility of prims now. + 1.5.22 (2026-04-20) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-newton_renderer-rgb.png index 84e0672a0c924dfb0e7dee43e582816f8a3123f2..ccd9c862aff4087177fc3b3e0a810f343c7b4fde 100644 GIT binary patch literal 5780 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 literal 6628 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 ik;4QNOfbQA@&5t3^JM3LRl+U+0000 zdyrgJoyWiD+9u%R?#g0fr!p>k2MaqQaJ-KmrTJ zW2q%(cNMI~qSP&Fm0EXQcEz={%9Yi{)C!brp@>*SFvO5~BuvkA_kErB{&DW~bRHyR zl9}$C?N3c5-S>8%IcL82ci!js1A6r6acGGBuU4ydHXkiky}2qBD1qzXGatYM--R|% z3I-s60RUWf`JcJ2>o|_myXepIVf0B)zVu|FrBh@E?pF>r~kkM48J z>T%(FKiF(GT-Pg?%bfF0VZ+9$sVPE` zramD=DGi`J{cjDY!`VKzbX{8lU}R+U;)~xuIyyQqFd(H;N->s=jg4*JzCB4|t#ufN zgwTP30RYSJWVwWY*#>l3Dds_!wj}_@$HxE+509)`wffw1&ke({(P&IgPDW94a&k|p zRN|ZyLIG6CuT#I%**(^Dqa>DziE#kK!z1k%2_cLz&Y9yl05nC}M_B4i4&OL;-g)h+n7VCjjz{*ruVcudl-LTV}SaVskAO04P28-ENnpoyp0G*6WdxQ7NT1*jq=qkad@> z-*3JQBURYPyf4HDLfmF~2QX=I@7%n`!`uNZHQRU+w|HQIo%ZxFcbFH zz_>05d`d}{Wm;>kwJ~|E8fWy-QHFqiY*C%vB??^_#+-euDa?I>h}*f zQ&0jzAQdtWCcp%g#!S4=hc|54I5;>cr8Gu!E{rk8

GnX{6V0{|hjs?5H%6kd{r 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
^?DLWLdU*_p7Z$;yA9? z8;;{z@qFKJcO-Z`NU~^0e%7x)D`Efp+uc|`1gSuPgwKV4xBH%if&uIyv2wX=_ntlU z&{x{iu}83^6r6J#chs07yW2dFQmM3XiRaU60Dy}wx~P$0&kSCD4O0!&BGlu&;;P3; z7^DIhFa}{5x~^;cxZAgH-!ILXnHk2I5TdnM*G>nlWb@{~v@fzOZC$+o{x8lu!B+Qi z;i%PW)oOLimcL&6W@rP|z>;#_*D^rH^DPZrf&fgwddqcPmk{#EBj4CBwOzY*o_F5) z_H||In?VU)n+_eYk|&@1S(c^kdK@LKgO>}6wH3xO6JaL8SZJ zA}pa2z;!?gzV!sq5i9Y}xboR&pS7=F(5JS3=AGbTuDzdf>6T`Sj3Z7_kCF0xKZDZw z=8gU`&U*Jo-}lGH#$Hbk0BbgGnW*8FNleaQ_Y@|lvA3Sz>P++QpK;|;%Fxc(;;xds z{jx3f1TjOx5T%GRWCEwHM9Ia8OR!??hvPUl#(eL4|8eMa46tVgJ0|eTB!0gauTJIH zkB1)DnvIJcmF%>SY+)krXxS}s+jrxPcYkc)Eg!Ppy`O&isna&zdFVveUcLoD#!-(k z)x>nM7J9pobysWwpft3>Y45yv>08g6n3zb@20U4xojROP>R6{s{I%!W9J^AXC)nH&BiUu265&oc<1W;N>Qz3r2bU3I%MPH zxUReSQGO<$_{xJ%;JWLsw&K3M$E4*)OW2qd^e3u1hIhoYDJ1 zdGNu%KgtxAPycM`%9TM7_`Nt&io$}Gz&l|DjvgS_Qd$VfInNuH!nP%^zWOTX zf^%j=G}wy;lVt6N+igf0DMhRMTilAvZqw#mNGZ}ZU->SWBmio)={Sz2rlzEnQYwHf z!gh!S!bZEa@k^8{Z1mAIO*rRS#-vo9X9!u09V!cil){F|u)DS`#j}#N8}6vr8?$j0 zxl)?a;+4W8rOGmqqzZ>M4y9CqqmNqRIJQdI<1?Mk&it{#ZQDc2 z6s*TJ7as=%AWF$1de3i_LJ0Aq=en-zd8JlC3T7p(_&8c?$8iXuN~zZKRTNsKc>M7n zX{`w%HbjHHTQDnW#mAWqW}VaNd>t9>rSN?}49oUhuf1Ea!nDW734*Xvsg%p*!i`Jz zD}{~n)4E$ENe&wyrx5g_{B#}1M=5&r=qUdOrlHljlI$`f00000NkvXXu0mjf0I#TA literal 5798 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 Ye*D;P) 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 diff --git a/source/isaaclab_visualizers/setup.py b/source/isaaclab_visualizers/setup.py index 2dfe9abd30fa..fc120619787b 100644 --- a/source/isaaclab_visualizers/setup.py +++ b/source/isaaclab_visualizers/setup.py @@ -17,16 +17,16 @@ "kit": [], "newton": [ "warp-lang", - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", "PyOpenGL-accelerate", "imgui-bundle>=1.92.5", ], "rerun": [ - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", "rerun-sdk>=0.29.0", ], "viser": [ - "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", "viser>=1.0.16", ], } diff --git a/tools/wheel_builder/res/python_packages.toml b/tools/wheel_builder/res/python_packages.toml index d79ce41ada84..f6a42b90a1bc 100644 --- a/tools/wheel_builder/res/python_packages.toml +++ b/tools/wheel_builder/res/python_packages.toml @@ -83,9 +83,9 @@ pyproject.optional-dependencies.all = [ # ================================================================================ { "newton" = [ "warp-lang==1.12.0", - "mujoco==3.5.0", - "mujoco-warp==3.5.0.2", - "newton==1.0.0", + "mujoco==3.6.0", + "mujoco-warp==3.6.0", + "newton @ git+https://github.com/newton-physics/newton.git@a27277ed49d6f307b8a1e4c394be7e1d14965a62", "PyOpenGL-accelerate==3.1.10" ] }, # ================================================================================ From b8a004aaa5f48c14452df935a86f64ee8f347dd3 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Tue, 21 Apr 2026 22:17:47 +0000 Subject: [PATCH 15/37] further remvoe original impl & clean --- docs/source/features/visualization.rst | 2 +- .../core-concepts/scene_data_providers.rst | 28 +- source/isaaclab/isaaclab/app/app_launcher.py | 8 +- .../physics/base_scene_data_provider.py | 8 +- .../physics/scene_data_requirements.py | 6 +- .../isaaclab/sim/simulation_context.py | 13 +- ...scene_data_provider_visualizer_contract.py | 52 +-- .../test_simulation_context_visualizers.py | 20 +- .../newton_scene_data_provider.py | 24 +- source/isaaclab_newton/setup.py | 1 - .../physx_scene_data_provider.py | 326 ++++-------------- .../newton/newton_visualizer.py | 6 +- .../rerun/rerun_visualizer.py | 4 +- .../viser/viser_visualizer.py | 4 +- 14 files changed, 137 insertions(+), 365 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 9c04bc9c9e4f..a708c62381d4 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -150,7 +150,7 @@ There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments f - ``max_visible_envs`` caps how many envs are shown. - ``visible_env_indices`` explicitly selects the envs to visualize. - ``randomly_sample_visible_envs`` (default ``True``): when ``visible_env_indices`` is unset and ``max_visible_envs`` is set, - pick that many env indices uniformly at random once at init (sorted). + pick that many env indices uniformly at random. .. note:: ``max_visible_envs=None`` means no cap (every environment); random sampling does not run in that case. diff --git a/docs/source/overview/core-concepts/scene_data_providers.rst b/docs/source/overview/core-concepts/scene_data_providers.rst index 684dfcefcbef..dc347678e118 100644 --- a/docs/source/overview/core-concepts/scene_data_providers.rst +++ b/docs/source/overview/core-concepts/scene_data_providers.rst @@ -27,9 +27,9 @@ The system has three layers: 1. **BaseSceneDataProvider** — abstract interface defining the contract: - - ``update(env_ids)`` — refresh cached scene data + - ``update()`` — refresh cached scene data (full Newton model/state sync when applicable) - ``get_newton_model()`` — return Newton model handle (if available) - - ``get_newton_state(env_ids)`` — return Newton state handle (if available) + - ``get_newton_state()`` — return Newton state handle (if available) - ``get_usd_stage()`` — return USD stage handle (if available) - ``get_transforms()`` — return body transforms - ``get_velocities()`` — return body velocities @@ -48,26 +48,24 @@ The system has three layers: PhysX Scene Data Provider ------------------------- -When PhysX is the active physics backend, the provider **builds and maintains a Newton model -from the USD stage**, then syncs PhysX transforms into it each frame. This is necessary because -Newton-based visualizers (Newton, Rerun, Viser) require a Newton model/state to render. +When PhysX is the active physics backend, the provider **loads the Newton model and state from +the interactive scene** via :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`, +then each frame it writes simulated body poses from PhysX into that Newton state for visualizers +(Newton, Rerun, Viser) that need it. -The sync pipeline: +The pose pipeline: -1. Reads transforms from PhysX ``RigidBodyView`` (fast tensor API) -2. Falls back to ``XformPrimView`` for bodies not covered by the rigid body view -3. Converts and writes merged poses into the Newton state via Warp kernels +1. Prefer PhysX ``RigidBodyView`` transforms (tensor API). +2. For any body not covered by that view, read poses via ``XformPrimView`` on the USD stage. +3. Merge and write poses into Newton ``body_q`` with Warp kernels. Newton Scene Data Provider -------------------------- -When Newton is the active physics backend, the provider **delegates directly to the Newton -manager** — no building or syncing required. Newton already owns the authoritative model and -state. +When Newton is the active physics backend, the provider returns the **NewtonManager** model and state handles. -The only additional work is **optional USD sync**: when an Omniverse Kit visualizer is active, -the provider syncs Newton transforms to the USD stage so Kit can render them. For Newton-only -or Rerun/Viser visualizers, this sync is skipped. +When a Kit visualizer is active, the provider can **sync transforms to the USD stage** for Kit rendering. +For Rerun or Viser without Kit, that USD sync is not needed and is skipped. Data Requirements ----------------- diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 4604ca788821..63eec8b92934 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -366,10 +366,10 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: - Multiple visualizers can be specified as a comma-delimited list: ``--viz rerun,newton,viser``. - * ``max_visible_envs`` (int | None): Overrides ``VisualizerCfg.max_visible_envs`` for the run: - contiguous env count when ``visible_env_indices`` is unset, or truncation length for explicit index lists - (newton, rerun, viser, kit). ``visible_env_indices`` is config-only, not a CLI flag. - + * ``max_visible_envs`` (int | None): Optional global override for enabling partial visualizaiton by + capping the number of environments show in the visualizers, which can improve performance. + More partial visualization configuration fields are available in the VisualizerCfg class. + .. _`WebRTC`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client Args: diff --git a/source/isaaclab/isaaclab/physics/base_scene_data_provider.py b/source/isaaclab/isaaclab/physics/base_scene_data_provider.py index e5b709da0ce7..9760a71d25ba 100644 --- a/source/isaaclab/isaaclab/physics/base_scene_data_provider.py +++ b/source/isaaclab/isaaclab/physics/base_scene_data_provider.py @@ -15,8 +15,8 @@ class BaseSceneDataProvider(ABC): """Backend-agnostic scene data provider interface.""" @abstractmethod - def update(self, env_ids: list[int] | None = None) -> None: - """Refresh any cached scene data.""" + def update(self) -> None: + """Refresh any cached scene data (full model/state).""" raise NotImplementedError @abstractmethod @@ -25,8 +25,8 @@ def get_newton_model(self) -> Any | None: raise NotImplementedError @abstractmethod - def get_newton_state(self, env_ids: list[int] | None = None) -> Any | None: - """Return Newton state handle when available.""" + def get_newton_state(self) -> Any | None: + """Return Newton state handle when available (full state).""" raise NotImplementedError @abstractmethod diff --git a/source/isaaclab/isaaclab/physics/scene_data_requirements.py b/source/isaaclab/isaaclab/physics/scene_data_requirements.py index 616592d9b1e0..49947342fa9b 100644 --- a/source/isaaclab/isaaclab/physics/scene_data_requirements.py +++ b/source/isaaclab/isaaclab/physics/scene_data_requirements.py @@ -26,10 +26,10 @@ class SceneDataRequirement: @dataclass(frozen=True) class VisualizerPrebuiltArtifacts: - """Prebuilt model/state payload shared from scene setup to providers. + """Newton model/state and rigid-body paths produced during scene clone setup. - This gets produced during clone-time visualizer prebuild and then read by - scene data providers as a fast path (instead of rebuilding from USD). + The PhysX scene data provider reads this from the simulation context when Newton + visualizers are active. """ model: Any diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 9a43056430cb..37942bdf929e 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -296,8 +296,8 @@ def _init_usd_physics_scene(self) -> None: UsdPhysics.SetStageKilogramsPerUnit(self.stage, 1.0) # Find and delete any existing physics scene. - # Collect paths first to avoid mutating the stage while traversing - # (iterator invalidation during deletion). + # Collect paths first to avoid mutating the stage while traversing, + # which can invalidate the USD iterator. physics_scene_paths = [ prim.GetPath().pathString for prim in self.stage.Traverse() if prim.GetTypeName() == "PhysicsScene" ] @@ -751,14 +751,7 @@ def update_scene_data_provider(self, force_require_forward: bool = False): self._visualizer_step_counter += 1 if self._scene_data_provider is None: return - provider = self._scene_data_provider - env_ids_union: list[int] = [] - for viz in self._visualizers: - ids = viz.get_visualized_env_ids() - if ids is not None: - env_ids_union.extend(ids) - env_ids = list(dict.fromkeys(env_ids_union)) if env_ids_union else None - provider.update(env_ids) + self._scene_data_provider.update() def _should_forward_before_visualizer_update(self) -> bool: """Return True if any visualizer requires pre-step forward kinematics.""" diff --git a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py b/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py index 8313069b996d..927fe351d202 100644 --- a/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py +++ b/source/isaaclab/test/sim/test_physx_scene_data_provider_visualizer_contract.py @@ -8,6 +8,7 @@ from __future__ import annotations from types import SimpleNamespace +from unittest.mock import patch from isaaclab_physx.scene_data_providers import PhysxSceneDataProvider @@ -15,23 +16,21 @@ def _make_provider(): - provider = object.__new__(PhysxSceneDataProvider) - provider._force_usd_fallback_for_newton_model_build = False - return provider + return object.__new__(PhysxSceneDataProvider) -def test_get_newton_model_for_env_ids_returns_full_model(): - """Filtered partial USD models were removed; callers always receive the full Newton model.""" +def test_get_newton_model_returns_model_when_sync_enabled(): + """Callers receive the full Newton model from :meth:`get_newton_model`.""" provider = _make_provider() provider._needs_newton_sync = True provider._newton_model = "full-model" - assert provider.get_newton_model_for_env_ids(None) == "full-model" - assert provider.get_newton_model_for_env_ids([3, 1]) == "full-model" + assert provider.get_newton_model() == "full-model" -def test_try_use_prebuilt_artifact_populates_provider_state(): - """Provider should consume scene-time prebuilt artifact as fast path.""" +@patch("isaaclab_physx.scene_data_providers.physx_scene_data_provider.replace_newton_shape_colors", lambda m, s: None) +def test_load_prebuilt_artifact_populates_provider_state(): + """Loading the prebuilt artifact sets model, state, and rigid-body paths.""" provider = _make_provider() artifact = VisualizerPrebuiltArtifacts( model="prebuilt-model", @@ -41,6 +40,7 @@ def test_try_use_prebuilt_artifact_populates_provider_state(): num_envs=4, ) provider._simulation_context = SimpleNamespace(get_scene_data_visualizer_prebuilt_artifact=lambda: artifact) + provider._stage = None provider._xform_views = {"old": object()} provider._view_body_index_map = {"old": [1]} @@ -50,14 +50,13 @@ def test_try_use_prebuilt_artifact_populates_provider_state(): provider._orientations_buf = object() provider._covered_buf = object() provider._xform_mask_buf = object() - provider._env_id_to_body_indices = {0: [0]} - - assert provider._try_use_prebuilt_newton_artifact() is True + provider._load_newton_model_from_prebuilt_artifact() assert provider._newton_model == "prebuilt-model" assert provider._newton_state == "prebuilt-state" assert provider._rigid_body_paths == ["/World/envs/env_0/A"] assert provider._rigid_body_view_paths == ["/World/envs/env_0/A", "/World/envs/env_0/Robot"] assert provider._num_envs_at_last_newton_build == 4 + assert provider._last_newton_model_build_source == "prebuilt" assert provider._xform_views == {} assert provider._view_body_index_map == {} assert provider._view_order_tensors == {} @@ -66,28 +65,13 @@ def test_try_use_prebuilt_artifact_populates_provider_state(): assert provider._orientations_buf is None assert provider._covered_buf is None assert provider._xform_mask_buf is None - assert provider._env_id_to_body_indices == {} -def test_try_use_prebuilt_artifact_respects_force_usd_fallback_flag(): - """Force flag should disable prebuilt fast path even when artifact is available.""" +def test_load_prebuilt_artifact_missing_sets_error_state(): + """When no artifact is registered, model/state stay unset.""" provider = _make_provider() - provider._force_usd_fallback_for_newton_model_build = True - artifact = VisualizerPrebuiltArtifacts( - model="prebuilt-model", - state="prebuilt-state", - rigid_body_paths=["/World/envs/env_0/A"], - articulation_paths=["/World/envs/env_0/Robot"], - num_envs=4, - ) - provider._simulation_context = SimpleNamespace(get_scene_data_visualizer_prebuilt_artifact=lambda: artifact) - - assert provider._try_use_prebuilt_newton_artifact() is False - - -def test_build_newton_model_from_usd_short_circuits_when_prebuilt_available(): - """If prebuilt artifact is available, USD fallback should not run.""" - provider = _make_provider() - provider._try_use_prebuilt_newton_artifact = lambda: True - provider._build_newton_model_from_usd() - assert provider._last_newton_model_build_source == "prebuilt" + provider._simulation_context = SimpleNamespace(get_scene_data_visualizer_prebuilt_artifact=lambda: None) + provider._load_newton_model_from_prebuilt_artifact() + assert provider._last_newton_model_build_source == "missing" + assert provider._newton_model is None + assert provider._newton_state is None diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index d3f1d31c289e..fe1eb6f040b5 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -30,8 +30,8 @@ class _FakeProvider: def __init__(self): self.update_calls = [] - def update(self, env_ids=None): - self.update_calls.append(env_ids) + def update(self): + self.update_calls.append(True) class _FakeVisualizer: @@ -102,7 +102,7 @@ def _make_context(visualizers, provider=None): return ctx -def test_update_scene_data_provider_unions_env_ids_and_forwards(): +def test_update_scene_data_provider_forwards_and_updates_provider(): provider = _FakeProvider() viz_a = _FakeVisualizer(env_ids=[0, 2], requires_forward=True) viz_b = _FakeVisualizer(env_ids=[2, 3]) @@ -112,7 +112,7 @@ def test_update_scene_data_provider_unions_env_ids_and_forwards(): ctx.update_scene_data_provider() assert ctx.physics_manager.forward_calls == 1 - assert provider.update_calls == [[0, 2, 3]] + assert provider.update_calls == [True] assert ctx._visualizer_step_counter == 1 @@ -121,7 +121,7 @@ def test_update_scene_data_provider_force_forward_with_no_visualizers(): ctx = _make_context([], provider=provider) ctx.update_scene_data_provider(force_require_forward=True) assert ctx.physics_manager.forward_calls == 1 - assert provider.update_calls == [None] + assert provider.update_calls == [True] def test_update_visualizers_removes_closed_nonrunning_and_failed(caplog): @@ -177,9 +177,9 @@ def get_metadata(self) -> dict: def get_newton_model(self): return "dummy-model" - def get_newton_state(self, env_ids: list[int] | None): - self.state_calls.append(env_ids) - return {"state_call": len(self.state_calls), "env_ids": env_ids} + def get_newton_state(self): + self.state_calls.append(None) + return {"state_call": len(self.state_calls)} class _DummyViserViewer: @@ -348,8 +348,8 @@ def get_metadata(self) -> dict: def get_newton_model(self): return "dummy-model" - def get_newton_state(self, env_ids: list[int] | None): - return {"env_ids": env_ids} + def get_newton_state(self): + return {"ok": True} monkeypatch.setattr(rerun_visualizer, "NewtonViewerRerun", _FakeNewtonViewerRerun) monkeypatch.setattr( diff --git a/source/isaaclab_newton/isaaclab_newton/scene_data_providers/newton_scene_data_provider.py b/source/isaaclab_newton/isaaclab_newton/scene_data_providers/newton_scene_data_provider.py index f3b10dc40044..ba19f4e7c63a 100644 --- a/source/isaaclab_newton/isaaclab_newton/scene_data_providers/newton_scene_data_provider.py +++ b/source/isaaclab_newton/isaaclab_newton/scene_data_providers/newton_scene_data_provider.py @@ -96,7 +96,7 @@ def _determine_num_envs_in_scene(self) -> int: # ---- Core provider API ------------------------------------------------------------------- - def update(self, env_ids: list[int] | None = None) -> None: + def update(self) -> None: """Sync Newton body transforms to USD Fabric when a Kit viewport is active. Called at render cadence by :meth:`~isaaclab.sim.SimulationContext.update_scene_data_provider`, @@ -104,9 +104,6 @@ def update(self, env_ids: list[int] | None = None) -> None: :meth:`~isaaclab_newton.physics.NewtonManager.sync_transforms_to_usd` when a Kit (or other USD-based) visualizer is in use. When both sim and rendering backend are Newton (or Rerun), the sync is skipped to avoid unnecessary slowdown. - - Args: - env_ids: Optional environment id selection. Unused in this provider. """ if not self._needs_usd_sync: return @@ -127,13 +124,9 @@ def get_newton_model(self) -> Any | None: return NewtonManager.get_model() - def get_newton_state(self, env_ids: list[int] | None = None) -> Any | None: + def get_newton_state(self) -> Any | None: """Return Newton state from NewtonManager. - Args: - env_ids: Optional list of environment IDs. Currently returns the full - state for all environments (env_ids filtering is not yet implemented). - Returns: The current Newton state (state_0) from NewtonManager. """ @@ -149,16 +142,9 @@ def get_model(self) -> Any | None: """ return self.get_newton_model() - def get_state(self, env_ids: list[int] | None = None) -> Any | None: - """Alias for :meth:`get_newton_state` for visualizer compatibility. - - Args: - env_ids: Optional list of environment ids. - - Returns: - Newton state object, or ``None`` when unavailable. - """ - return self.get_newton_state(env_ids) + def get_state(self) -> Any | None: + """Alias for :meth:`get_newton_state` for visualizer compatibility.""" + return self.get_newton_state() def get_usd_stage(self) -> Any | None: """Return the USD stage handle. diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index 256cdb601e32..137c6742f2c9 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -45,7 +45,6 @@ def run(self): "mujoco==3.5.0", "mujoco-warp==3.5.0.2", "PyOpenGL-accelerate==3.1.10", - # Includes PR #2267: ViewerBase.set_visible_worlds() for Rerun/Viser/GL world filtering. "newton @ git+https://github.com/newton-physics/newton.git@7e036f542437046f2dc14028e189cb8428afd191", ], } diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py index 2f83fde884a4..8f3e004e4cb5 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py @@ -22,9 +22,6 @@ logger = logging.getLogger(__name__) -# Path pattern for env prims: /World/envs/env_/... -_ENV_ID_RE = re.compile(r"/World/envs/env_(\d+)") - @wp.kernel(enable_backward=False) def _set_body_q_kernel( @@ -37,36 +34,18 @@ def _set_body_q_kernel( body_q[i] = wp.transformf(positions[i], orientations[i]) -@wp.kernel(enable_backward=False) -def _set_body_q_subset_kernel( - positions: wp.array(dtype=wp.vec3), - orientations: wp.array(dtype=wp.quatf), - body_indices: wp.array(dtype=wp.int32), - body_q: wp.array(dtype=wp.transformf), -): - """Write pose arrays into selected Newton ``body_q`` indices.""" - i = wp.tid() - bi = body_indices[i] - body_q[bi] = wp.transformf(positions[i], orientations[i]) - - class PhysxSceneDataProvider(BaseSceneDataProvider): """Scene data provider for Omni PhysX backend. Supports: - - body poses via PhysX tensor views, with XformPrimView fallback + - rigid-body poses via PhysX tensor views and ``XformPrimView`` where needed - camera poses & intrinsics - USD stage handles - - Newton model/state handles + - Newton model/state (from the simulation context prebuilt payload when required) """ # ---- Environment discovery / metadata ------------------------------------------------- - def _env_id_from_path(self, path: str) -> int | None: - """Extract env id from path (e.g. /World/envs/env_42/...). Used to map body paths to envs for sync.""" - m = _ENV_ID_RE.search(path) - return int(m.group(1)) if m else None - def get_num_envs(self) -> int: """Return env count from stage discovery, cached once available.""" if self._num_envs is not None and self._num_envs > 0: @@ -118,10 +97,6 @@ def __init__(self, stage, simulation_context) -> None: requirements = self._simulation_context.get_scene_data_requirements() self._needs_newton_sync = bool(requirements.requires_newton_model) - # Benchmark/debug override: force USD traversal fallback even when prebuilt - # visualizer artifacts are available from the cloner path. - self._force_usd_fallback_for_newton_model_build = False - # Fixed metadata for visualizers. get_metadata() returns this plus num_envs so visualizers # can .get("num_envs", 0), .get("physics_backend", ...) etc. without the provider exposing many methods. self._metadata = {"physics_backend": "omni"} @@ -130,7 +105,6 @@ def __init__(self, stage, simulation_context) -> None: "[PhysxSceneDataProvider] USD stage is None and not available from simulation_context. " "Ensure the simulation context has a valid stage when using OV/Newton/Rerun/Viser visualizers." ) - self._up_axis = UsdGeom.GetStageUpAxis(self._stage) self._num_envs_at_last_newton_build: int | None = None # for _refresh_newton_model_if_needed self._device = getattr(self._simulation_context, "device", "cuda:0") @@ -139,8 +113,6 @@ def __init__(self, stage, simulation_context) -> None: self._rigid_body_paths: list[str] = [] # Paths used to create PhysX views. May include articulation roots for coverage. self._rigid_body_view_paths: list[str] = [] - # env_id -> list of body indices (in Newton body_key order) - self._env_id_to_body_indices: dict[int, list[int]] = {} # Reused pose buffers (MR perf): avoid per-call allocations in _read_poses_from_best_source. self._pose_buf_num_bodies = 0 @@ -150,14 +122,12 @@ def __init__(self, stage, simulation_context) -> None: self._xform_mask_buf = None # View index order as device tensors for vectorized scatter in _apply_view_poses. self._view_order_tensors: dict[str, Any] = {} - # Last full-model build source for tests/debugging ("prebuilt", "usd_fallback", "error"). + # Last load outcome (tests / debug): "prebuilt" | "missing" | "error". self._last_newton_model_build_source: str | None = None self._last_newton_model_build_elapsed_ms: float | None = None - # Initialize Newton pipeline only if needed for visualization if self._needs_newton_sync: - self._build_newton_model_from_usd() - self._build_env_id_to_body_indices() + self._load_newton_model_from_prebuilt_artifact() self._setup_rigid_body_view() # ---- Newton model + PhysX view setup -------------------------------------------------- @@ -170,7 +140,7 @@ def _wildcard_env_paths(self, paths: list[str]) -> list[str]: return list(dict.fromkeys(wildcard_paths)) if wildcard_paths else paths def _refresh_newton_model_if_needed(self) -> None: - """Rebuild Newton model/state and PhysX views if discovered env count changes.""" + """Reload Newton model/state and PhysX views when the discovered env count changes.""" num_envs = self.get_num_envs() if num_envs <= 0: return @@ -178,8 +148,7 @@ def _refresh_newton_model_if_needed(self) -> None: needs_rebuild = self._newton_model is None or self._newton_state is None needs_rebuild = needs_rebuild or (self._num_envs_at_last_newton_build != num_envs) if needs_rebuild: - self._build_newton_model_from_usd() - self._build_env_id_to_body_indices() + self._load_newton_model_from_prebuilt_artifact() self._setup_rigid_body_view() def _model_body_paths(self, model) -> list[str]: @@ -195,86 +164,46 @@ def _model_body_paths(self, model) -> list[str]: return [] return list(getattr(model, "body_label", None) or getattr(model, "body_key", [])) - def _try_use_prebuilt_newton_artifact(self) -> bool: - """Use scene-time prebuilt Newton visualizer artifact when available. - - Returns: - ``True`` when a valid prebuilt artifact was consumed, otherwise ``False``. - """ - if self._force_usd_fallback_for_newton_model_build: - return False - artifact = self._simulation_context.get_scene_data_visualizer_prebuilt_artifact() - if not artifact: - return False - - model = artifact.model - state = artifact.state - if model is None or state is None: - return False - - self._newton_model = model - self._newton_state = state - - # The Newton artifact was generated before all envs were cloned on the stage, so we update the shape colors - # in the Newton model here as the envs should have been cloned. - replace_newton_shape_colors(self._newton_model, self._stage) - - body_paths = list(artifact.rigid_body_paths) or self._model_body_paths(model) - # Keep one-to-one alignment between `body_paths` and Newton `state.body_q`. - # Articulation root prims are not body_q entries and must not be mixed here. - self._rigid_body_paths = body_paths - # Build the PhysX-view query set separately so articulation roots can still be - # included for view coverage without breaking body_q alignment. - view_paths = list(body_paths) - if artifact.articulation_paths: - seen = set(view_paths) - for path in artifact.articulation_paths: - if path not in seen: - view_paths.append(path) - seen.add(path) - self._rigid_body_view_paths = view_paths - self._xform_views.clear() - self._view_body_index_map = {} - self._view_order_tensors.clear() - self._pose_buf_num_bodies = 0 - self._positions_buf = None - self._orientations_buf = None - self._covered_buf = None - self._xform_mask_buf = None - self._env_id_to_body_indices = {} - self._num_envs_at_last_newton_build = int(artifact.num_envs) - return True - - def _build_newton_model_from_usd(self) -> None: - """Build Newton model from USD and cache body paths.""" - # TODO: Deprecate this USD-traversal fallback once cloner/prebuilt coverage - # is complete for full and partial visualization model-build paths. + def _load_newton_model_from_prebuilt_artifact(self) -> None: + """Load Newton model and state from the simulation context prebuilt artifact.""" start_t = time.perf_counter() try: - if self._try_use_prebuilt_newton_artifact(): - self._last_newton_model_build_source = "prebuilt" + artifact = self._simulation_context.get_scene_data_visualizer_prebuilt_artifact() + if not artifact: + self._last_newton_model_build_source = "missing" + logger.error( + "[PhysxSceneDataProvider] No visualizer prebuilt artifact on the simulation context " + "(expected VisualizerPrebuiltArtifacts from scene setup)." + ) + self._clear_newton_model_state() return - self._last_newton_model_build_source = ( - "usd_fallback_forced" if self._force_usd_fallback_for_newton_model_build else "usd_fallback" - ) - from newton import ModelBuilder - builder = ModelBuilder(up_axis=self._up_axis) - builder.add_usd(self._stage, ignore_paths=[r"/World/envs/.*"]) - for env_id in range(self.get_num_envs()): - builder.begin_world() - builder.add_usd(self._stage, root_path=f"/World/envs/env_{env_id}") - builder.end_world() + model = artifact.model + state = artifact.state + if model is None or state is None: + self._last_newton_model_build_source = "missing" + logger.error( + "[PhysxSceneDataProvider] Prebuilt artifact is missing model or state;" + " cannot sync PhysX to Newton." + ) + self._clear_newton_model_state() + return - self._newton_model = builder.finalize(device=self._device) - self._newton_state = self._newton_model.state() + self._newton_model = model + self._newton_state = state replace_newton_shape_colors(self._newton_model, self._stage) - # Extract scene structure from Newton model (single source of truth) - self._rigid_body_paths = self._model_body_paths(self._newton_model) - self._rigid_body_view_paths = list(self._rigid_body_paths) - + body_paths = list(artifact.rigid_body_paths) or self._model_body_paths(model) + self._rigid_body_paths = body_paths + view_paths = list(body_paths) + if artifact.articulation_paths: + seen = set(view_paths) + for path in artifact.articulation_paths: + if path not in seen: + view_paths.append(path) + seen.add(path) + self._rigid_body_view_paths = view_paths self._xform_views.clear() self._view_body_index_map = {} self._view_order_tensors.clear() @@ -283,23 +212,12 @@ def _build_newton_model_from_usd(self) -> None: self._orientations_buf = None self._covered_buf = None self._xform_mask_buf = None - self._env_id_to_body_indices = {} - self._num_envs_at_last_newton_build = self.get_num_envs() - except ModuleNotFoundError as exc: - self._last_newton_model_build_source = "error" - logger.error( - "[PhysxSceneDataProvider] Newton module not available. " - "Install the Newton backend to use newton/rerun/viser visualizers." - ) - logger.debug(f"[PhysxSceneDataProvider] Newton import error: {exc}") + self._num_envs_at_last_newton_build = int(artifact.num_envs) + self._last_newton_model_build_source = "prebuilt" except Exception as exc: self._last_newton_model_build_source = "error" - logger.error(f"[PhysxSceneDataProvider] Failed to build Newton model from USD: {exc}") - self._newton_model = None - self._newton_state = None - self._rigid_body_paths = [] - self._rigid_body_view_paths = [] - self._num_envs_at_last_newton_build = None + logger.error("[PhysxSceneDataProvider] Failed to load Newton model from prebuilt artifact: %s", exc) + self._clear_newton_model_state() finally: elapsed_ms = (time.perf_counter() - start_t) * 1000.0 self._last_newton_model_build_elapsed_ms = elapsed_ms @@ -308,19 +226,19 @@ def _build_newton_model_from_usd(self) -> None: except Exception: num_envs = -1 logger.debug( - "[PhysxSceneDataProvider] Newton model build source=%s num_envs=%d elapsed_ms=%.2f", + "[PhysxSceneDataProvider] Newton model load source=%s num_envs=%d elapsed_ms=%.2f", self._last_newton_model_build_source, num_envs, elapsed_ms, ) - def _build_env_id_to_body_indices(self) -> None: - """Build mapping env_id -> list of body indices from rigid_body_paths.""" - self._env_id_to_body_indices = {} - for body_idx, path in enumerate(self._rigid_body_paths): - eid = self._env_id_from_path(path) - if eid is not None: - self._env_id_to_body_indices.setdefault(eid, []).append(body_idx) + def _clear_newton_model_state(self) -> None: + """Clear cached Newton model, state, and rigid-body path lists.""" + self._newton_model = None + self._newton_state = None + self._rigid_body_paths = [] + self._rigid_body_view_paths = [] + self._num_envs_at_last_newton_build = None def _setup_rigid_body_view(self) -> None: """Create PhysX RigidBodyView from Newton's body paths. @@ -479,7 +397,7 @@ def _apply_view_poses(self, view: Any, view_key: str, positions: Any, orientatio return newton_indices.numel() return 0 - # Fallback: Python loop when view does not fully cover or cache missing. + # Per-index path when the view does not fully cover bodies or the order cache is missing. count = 0 for newton_idx, view_idx in enumerate(order): if view_idx is not None and not covered[newton_idx]: @@ -491,10 +409,7 @@ def _apply_view_poses(self, view: Any, view_key: str, positions: Any, orientatio return count def _apply_xform_poses(self, positions: Any, orientations: Any, covered: Any, xform_mask: Any) -> int: - """Fill remaining poses using XformPrimView (USD fallback). - - This is slower but more robust when PhysX views don't cover all bodies. - """ + """Fill remaining body poses using ``XformPrimView`` for prims not covered by the rigid-body view.""" import torch from isaaclab.sim.views import XformPrimView @@ -527,19 +442,14 @@ def _apply_xform_poses(self, positions: Any, orientations: Any, covered: Any, xf if len(self._xform_view_failures) > 0: self._warn_once( "xform-fallback-failures", - "[PhysxSceneDataProvider] Xform fallback failed for %d body paths.", + "[PhysxSceneDataProvider] XformPrimView reads failed for %d body paths.", len(self._xform_view_failures), level=logging.DEBUG, ) return count def _convert_xform_quats(self, orientations: Any, xform_mask: Any) -> Any: - """Return quaternions in xyzw convention. - - PhysX views, XformPrimView, and resolve_prim_pose() in Isaac Lab all use xyzw. - Keeping this helper as a no-op preserves a single conversion point if conventions - ever diverge again. - """ + """Return quaternions in xyzw convention (passthrough; inputs already xyzw).""" return orientations def _read_poses_from_best_source(self) -> tuple[Any, Any, str, Any] | None: @@ -575,13 +485,12 @@ def _read_poses_from_best_source(self) -> tuple[Any, Any, str, Any] | None: covered = self._covered_buf xform_mask = self._xform_mask_buf - # Apply sources in preferred order: rigid bodies, then USD fallback. rigid_count = self._apply_view_poses(self._rigid_body_view, "rigid_body_view", positions, orientations, covered) xform_count = self._apply_xform_poses(positions, orientations, covered, xform_mask) if rigid_count == 0: self._warn_once( "rigid-source-unused", - "[PhysxSceneDataProvider] RigidBodyView did not provide any body transforms; using fallback sources.", + "[PhysxSceneDataProvider] RigidBodyView returned no transforms; filled from XformPrimView where needed.", level=logging.DEBUG, ) @@ -604,18 +513,10 @@ def _get_set_body_q_kernel(self): """Return module-level Warp kernel for writing transforms to Newton state.""" return _set_body_q_kernel - def _get_set_body_q_subset_kernel(self): - """Return module-level Warp kernel for subset writes.""" - return _set_body_q_subset_kernel - # ---- Newton state sync ---------------------------------------------------------------- - def update(self, env_ids: list[int] | None = None) -> None: - """Sync PhysX transforms to Newton state for visualization. - - When env_ids is not None, only body indices belonging to those envs are written - (partial sync). When None, all bodies are synced. - """ + def update(self) -> None: + """Sync PhysX transforms into the full Newton state (one kernel launch).""" if not self._needs_newton_sync or self._newton_state is None: return @@ -633,41 +534,15 @@ def update(self, env_ids: list[int] | None = None) -> None: positions_wp = wp.from_torch(positions.reshape(-1, 3), dtype=wp.vec3) orientations_wp = wp.from_torch(orientations_xyzw, dtype=wp.quatf) - if env_ids is None or not env_ids or not self._env_id_to_body_indices: - # Fast path: full state sync in one kernel launch. - set_body_q = self._get_set_body_q_kernel() - if set_body_q is None or positions_wp.shape[0] != self._newton_state.body_q.shape[0]: - return - wp.launch( - set_body_q, - dim=positions_wp.shape[0], - inputs=[positions_wp, orientations_wp, self._newton_state.body_q], - device=self._device, - ) - else: - body_indices = [] - for eid in env_ids: - body_indices.extend(self._env_id_to_body_indices.get(eid, [])) - if not body_indices: - return - # Subset path: write only env-selected body indices. - subset_kernel = self._get_set_body_q_subset_kernel() - if subset_kernel is None: - return - import torch - - indices_t = torch.tensor(body_indices, dtype=torch.int32, device=self._device) - pos_subset = positions.reshape(-1, 3)[body_indices] - ori_subset = orientations_xyzw[body_indices] - indices_wp = wp.from_torch(indices_t, dtype=wp.int32) - pos_wp = wp.from_torch(pos_subset.contiguous(), dtype=wp.vec3) - ori_wp = wp.from_torch(ori_subset.contiguous(), dtype=wp.quatf) - wp.launch( - subset_kernel, - dim=len(body_indices), - inputs=[pos_wp, ori_wp, indices_wp, self._newton_state.body_q], - device=self._device, - ) + set_body_q = self._get_set_body_q_kernel() + if set_body_q is None or positions_wp.shape[0] != self._newton_state.body_q.shape[0]: + return + wp.launch( + set_body_q, + dim=positions_wp.shape[0], + inputs=[positions_wp, orientations_wp, self._newton_state.body_q], + device=self._device, + ) except Exception as exc: self._warn_once( "newton-sync-update-failed", @@ -683,74 +558,11 @@ def get_newton_model(self) -> Any | None: """ return self._newton_model if self._needs_newton_sync else None - def get_newton_model_for_env_ids(self, env_ids: list[int] | None) -> Any | None: - """Return the full Newton model (``env_ids`` is ignored). - - Newton viewers select visible worlds via ``ViewerBase.set_visible_worlds`` using the full - model and full state; partial USD builds are no longer used. - """ - del env_ids - return self.get_newton_model() - - def get_newton_state(self, env_ids: list[int] | None = None) -> Any | None: - """Return Newton state when sync is enabled. - - If env_ids is None, returns the full state. If env_ids is provided, returns a - state-like object whose body_q contains only the bodies for those envs (same order - as in the full model). - """ + def get_newton_state(self) -> Any | None: + """Return full Newton state when sync is enabled.""" if not self._needs_newton_sync or self._newton_state is None: return None - if env_ids is None: - return self._newton_state - if not self._env_id_to_body_indices: - return self._create_empty_subset_state() - body_indices = [] - for eid in env_ids: - body_indices.extend(self._env_id_to_body_indices.get(eid, [])) - if not body_indices: - return self._create_empty_subset_state() - - body_q = self._newton_state.body_q - try: - import warp as wp - - body_q_t = wp.to_torch(body_q) - body_q_subset = body_q_t[body_indices].clone() - except Exception: - return self._newton_state - return self._create_subset_state(body_q_subset) - - def _create_empty_subset_state(self): - """Return a minimal state-like object with empty body_q.""" - if self._newton_state is None: - return None - try: - import warp as wp - - body_q_t = wp.to_torch(self._newton_state.body_q) - empty = body_q_t[:0].clone() - return self._create_subset_state(empty) - except Exception: - return self._newton_state - - # ---- Newton subset helpers ------------------------------------------------------------- - - def _create_subset_state(self, body_q_subset): - """Return a minimal state-like object for subset rendering.""" - import warp as wp - - if hasattr(body_q_subset, "device") and not isinstance(body_q_subset, wp.array): - body_q_subset = wp.from_torch(body_q_subset, dtype=wp.transformf) - - class _SubsetState: - """Minimal state carrier with ``body_q`` field for subset rendering.""" - - pass - - s = _SubsetState() - s.body_q = body_q_subset - return s + return self._newton_state # ---- Public provider API --------------------------------------------------------------- diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index f39681feabdf..4f34e6335ba2 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -287,7 +287,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._env_ids = self._compute_visualized_env_ids() # Full model + ViewerBase.set_visible_worlds() (Newton PR #2267); avoids cloning a reduced model. self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state(None) + self._state = scene_data_provider.get_newton_state() # Use pyglet's EGL headless backend when requested. Must run before the first # ``pyglet.window`` import so ``Window`` resolves to :class:`~pyglet.window.headless.HeadlessWindow`. @@ -368,13 +368,13 @@ def step(self, dt: float) -> None: if self._viewer is None: if self._scene_data_provider is not None: - self._state = self._scene_data_provider.get_newton_state(None) + self._state = self._scene_data_provider.get_newton_state() return if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() - self._state = self._scene_data_provider.get_newton_state(None) + self._state = self._scene_data_provider.get_newton_state() contacts = None if self._viewer.show_contacts: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py index 0944abe11ad2..5390802df69d 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/rerun/rerun_visualizer.py @@ -150,7 +150,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: num_envs = int(metadata.get("num_envs", 0)) self._env_ids = self._compute_visualized_env_ids() self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state(None) + self._state = scene_data_provider.get_newton_state() grpc_port = int(self.cfg.grpc_port) web_port = int(self.cfg.web_port) @@ -234,7 +234,7 @@ def step(self, dt: float) -> None: if self.cfg.cam_source == "prim_path": self._update_camera_from_usd_path() - self._state = self._scene_data_provider.get_newton_state(None) + self._state = self._scene_data_provider.get_newton_state() if not self._viewer.is_paused(): self._viewer.begin_frame(self._sim_time) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py index 3d801c9f330f..a629ab8b2fed 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/viser/viser_visualizer.py @@ -146,7 +146,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: metadata = scene_data_provider.get_metadata() self._env_ids = self._compute_visualized_env_ids() self._model = scene_data_provider.get_newton_model() - self._state = scene_data_provider.get_newton_state(None) + self._state = scene_data_provider.get_newton_state() self._active_record_path = self.cfg.record_to_viser self._create_viewer(record_to_viser=self.cfg.record_to_viser, metadata=metadata) @@ -182,7 +182,7 @@ def step(self, dt: float) -> None: self._update_camera_from_usd_path() self._apply_pending_camera_pose() - self._state = self._scene_data_provider.get_newton_state(None) + self._state = self._scene_data_provider.get_newton_state() self._sim_time += dt self._viewer.begin_frame(self._sim_time) self._viewer.log_state(self._state) From 3238a3f53d3671a0893df342b02db9e33693851f Mon Sep 17 00:00:00 2001 From: bdilinila <148156773+bdilinila@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:01:56 -0400 Subject: [PATCH 16/37] Add documentation for setup of perspective video recording (#5231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: bdilinila <148156773+bdilinila@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- docs/source/how-to/record_video.rst | 129 +++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/docs/source/how-to/record_video.rst b/docs/source/how-to/record_video.rst index aba743631295..01ee6240bb0c 100644 --- a/docs/source/how-to/record_video.rst +++ b/docs/source/how-to/record_video.rst @@ -3,6 +3,9 @@ Recording video clips during training Isaac Lab supports recording video clips during training using the `gymnasium.wrappers.RecordVideo `_ class. +When the ``--video`` flag is enabled, Isaac Lab captures a perspective view of the scene. The backend +is chosen automatically from the active physics and renderer stack: an Isaac Sim Kit camera or a +Newton GL headless viewer. This feature can be enabled by installing ``ffmpeg`` and using the following command line arguments with the training script: @@ -11,7 +14,6 @@ script: * ``--video_length``: length of each recorded video (in steps) * ``--video_interval``: interval between each video recording (in steps) -Make sure to also add the ``--enable_cameras`` argument when running headless. Note that enabling recording is equivalent to enabling rendering during training, which will slow down both startup and runtime performance. Example usage: @@ -23,3 +25,128 @@ Example usage: The recorded videos will be saved in the same directory as the training checkpoints, under ``IsaacLab/logs////videos/train``. + + +Overview +-------- + +The video recording feature is implemented using the ``VideoRecorder`` class. This class is responsible for resolving the video backend from the scene, capturing the video frames, and saving them to a file. + +* ``VideoRecorderCfg`` (``isaaclab.envs.utils.video_recorder_cfg``) holds resolution and world-space + perspective parameters ``camera_position`` and ``camera_target`` (defaults to a diagonal view of the + scene). +* ``VideoRecorder`` (``isaaclab.envs.utils.video_recorder``) picks a video backend from the scene + (Kit vs Newton GL), builds the matching low-level capture object, and returns RGB frames via + ``render_rgb_array()``. +* Direct RL, Direct MARL and manager-based RL environments copy the task's + :class:`~isaaclab.envs.common.ViewerCfg` ``eye`` and ``lookat`` into those fields before the + recorder is constructed, so training clips align with the task's intended viewport when + ``origin_type`` is ``"world"``. + + +Configuration: ``VideoRecorderCfg`` +------------------------------------ + +The dataclass lives in ``isaaclab.envs.utils.video_recorder_cfg``. Fields ``camera_position`` and +``camera_target`` are the perspective ``eye`` and ``lookat`` points in meters. + +.. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder_cfg.py + :language: python + :lines: 20-48 + + +Task framing: ``ViewerCfg`` +---------------------------- + +Tasks define the interactive viewer with :class:`~isaaclab.envs.common.ViewerCfg`. The ``eye`` and +``lookat`` tuples are the same values the RL base classes copy into ``VideoRecorderCfg`` (see below). +If your task uses ``origin_type="world"``, those tuples are world-space positions and match what the +perspective recorder expects. + +.. literalinclude:: ../../../source/isaaclab/isaaclab/envs/common.py + :language: python + :lines: 20-28 + + +Backend selection: Kit vs Newton GL +------------------------------------- + +``VideoRecorder`` resolves the implementation from the live :class:`~isaaclab.scene.InteractiveScene`. +If the user provides the PhysX physics (``presets=physx,...``) or Isaac RTX (``presets=isaac_rtx_renderer,...``) in the sensor stack, the Kit path is selected (``omni.replicator`` on +``/OmniverseKit_Persp``). The Newton GL path is selected when Newton physics is active (``presets=newton,...``) or the Newton +Warp renderer (``presets=newton_renderer,...``) appears in the sensor stack - and neither PhysX nor Isaac RTX is present to claim the +Kit path. OVRTX (``presets=ovrtx_renderer,...`` from ``isaaclab_ov``) can pair with IsaacSim or Newton physics; in that case the video backend is +selected via the physics preset. If both Kit and Newton GL signals are present (e.g., ``presets=physx,isaac_rtx_renderer,...`` or ``presets=newton,newton_renderer,...``), the Kit path is chosen. + +.. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder.py + :language: python + :lines: 38-59 + + +Construction and dispatch +-------------------------- + +When ``env_render_mode`` is ``"rgb_array"`` (as when wrappers or scripts request RGB frames for +video), the recorder instantiates the backend-specific helper and passes through ``camera_position``, +``camera_target``, and window size. + +.. literalinclude:: ../../../source/isaaclab/isaaclab/envs/utils/video_recorder.py + :language: python + :lines: 70-114 + + +Customising the camera view +---------------------------- + +When ``--video`` is passed, the recording camera uses the same +position and look-at target as the interactive viewer. The defaults come from +:class:`~isaaclab.envs.common.ViewerCfg`: + +* ``eye = (7.5, 7.5, 7.5)`` — camera position in world space (metres) +* ``lookat = (0.0, 0.0, 0.0)`` — camera look-at target in world space (metres) +* Resolution ``1280x720`` + +To change the recording angle, override the ``viewer`` field in your task's environment config. +The RL base classes automatically copy ``eye`` and ``lookat`` into ``VideoRecorderCfg`` before +recording starts (when ``origin_type`` is ``"world"``), so the video clip uses the same viewpoint +as the interactive viewport: + +.. code-block:: python + + from isaaclab.envs import ManagerBasedRLEnvCfg + from isaaclab.envs.common import ViewerCfg + from isaaclab.utils import configclass + + @configclass + class MyTaskCfg(ManagerBasedRLEnvCfg): + viewer: ViewerCfg = ViewerCfg( + eye=(5.0, 5.0, 5.0), + lookat=(0.0, 0.0, 1.0), + ) + + +Summary +------- + +.. list-table:: + :widths: 40 22 38 + :header-rows: 1 + + * - Stack example (``presets=...``) + - Video backend + - Capture mechanism + * - ``physx,...`` or ``isaac_rtx_renderer,...`` + - Kit (``"kit"``) + - ``/OmniverseKit_Persp`` + Replicator RGB + * - ``newton,...`` or ``newton_renderer,...`` (no Kit signals) + - Newton GL (``"newton_gl"``) + - ``newton.viewer.ViewerGL`` on the SDP Newton model + * - ``newton,...,ovrtx_renderer,...`` (OVRTX + Newton physics) + - Newton GL (``"newton_gl"``) + - ``newton.viewer.ViewerGL`` on the SDP Newton model + + +See also +-------- + +* :doc:`/source/features/visualization` - interactive visualizers From 6e99d588a645ef5b7057db83ac648809f428e971 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Tue, 21 Apr 2026 23:28:08 +0000 Subject: [PATCH 17/37] testin phase --- docs/source/features/visualization.rst | 40 ++++++++++- .../core-concepts/scene_data_providers.rst | 23 ++++--- source/isaaclab/isaaclab/app/app_launcher.py | 10 ++- .../physics/scene_data_requirements.py | 6 +- .../isaaclab/visualizers/base_visualizer.py | 6 +- .../isaaclab/visualizers/visualizer_cfg.py | 9 ++- .../physx_scene_data_provider.py | 3 +- .../isaaclab_tasks/utils/sim_launcher.py | 5 +- .../kit/kit_visualizer.py | 68 +++++++++++++++++-- .../kit/kit_visualizer_cfg.py | 2 +- .../newton/newton_visualizer.py | 1 - 11 files changed, 135 insertions(+), 38 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index a708c62381d4..0de052487452 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -150,7 +150,7 @@ There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments f - ``max_visible_envs`` caps how many envs are shown. - ``visible_env_indices`` explicitly selects the envs to visualize. - ``randomly_sample_visible_envs`` (default ``True``): when ``visible_env_indices`` is unset and ``max_visible_envs`` is set, - pick that many env indices uniformly at random. + enables randomly sampling the selected envs. If disabled, the first ``max_visible_envs`` envs are selected. .. note:: ``max_visible_envs=None`` means no cap (every environment); random sampling does not run in that case. @@ -159,7 +159,7 @@ There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments f Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. Note, in the current release, the KitVisualizer does not fully support partial visualization. The non-selected environments -are made invisible which does not improve performance much. +are made invisible which does not improve performance much. .. _visualization-common-modes: @@ -331,6 +331,27 @@ Rerun Visualizer record_to_rrd="recording.rrd", # Path to save .rrd file (None = no recording) ) +**Remote viewing (SSH / cloud / another machine):** + +Rerun serves two TCP ports by default: the **web UI** (``web_port``, default ``9090``) and the **gRPC** +endpoint (``grpc_port``, default ``9876``). Allow both inbound to the training host (or use a tunnel +that forwards both). + +On startup, the log prints a **RerunVisualizer Configuration** table with ``viewer_url``. For a host +reachable as ```` (DNS name or IP), the same shape as the code uses is: + +.. code-block:: text + + http://:9090/?url=rerun%2Bhttp%3A%2F%2F%3A9876%2Fproxy + +If you override ``web_port`` or ``grpc_port`` in ``RerunVisualizerCfg``, replace ``9090`` and ``9876`` in +both places and, if needed, take the exact ``viewer_url`` line from the log (it is built the same way as +``isaaclab_visualizers.rerun.rerun_visualizer._rerun_web_viewer_url``). + +Do not copy a ``viewer_url`` that still contains ``127.0.0.1`` or ``localhost`` and open it from a +**different** machine—the embedded ``rerun+http://â€Ķ/proxy`` address must use the training host’s +address that your browser can reach. + Rerun startup uses the Python SDK through ``newton.viewer.ViewerRerun`` (no external ``rerun`` CLI process management). If ``grpc_port`` is already active, Isaac Lab reuses that server. If ``web_port`` is occupied while starting a new server, initialization fails with a clear port-conflict error. @@ -350,6 +371,21 @@ server, allowing you to view and interact with the scene from any browser. - Recording to ``.viser`` format for replay - Environment filtering to control which environments are rendered +**Remote viewing (SSH / cloud / another machine):** + +The Viser HTTP server listens on ``port`` (default ``8080``; set ``ViserVisualizerCfg.port`` if you +change it). Allow that port inbound, then open: + +.. code-block:: text + + http://:8080 + +Use the machine’s hostname or IP for ````. On startup, the log prints **ViserVisualizer +Configuration** with ``viewer_url`` for the configured port (defaults to ``http://localhost:``—replace +``localhost`` with your remote host when connecting from another device). + +You can also enable ``share=True`` in ``ViserVisualizerCfg`` to request a public share URL from Viser when supported. + **Launch with Viser:** .. code-block:: bash diff --git a/docs/source/overview/core-concepts/scene_data_providers.rst b/docs/source/overview/core-concepts/scene_data_providers.rst index dc347678e118..8b6443e258c0 100644 --- a/docs/source/overview/core-concepts/scene_data_providers.rst +++ b/docs/source/overview/core-concepts/scene_data_providers.rst @@ -49,23 +49,26 @@ PhysX Scene Data Provider ------------------------- When PhysX is the active physics backend, the provider **loads the Newton model and state from -the interactive scene** via :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`, -then each frame it writes simulated body poses from PhysX into that Newton state for visualizers -(Newton, Rerun, Viser) that need it. +the interactive scene’s cloner prebuilt artifact** (see :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`), +then syncs PhysX transforms into that state each frame. Newton-based visualizers (Newton, Rerun, +Viser) require this model/state to render; there is no separate USD traversal build in the provider. -The pose pipeline: +The sync pipeline: -1. Prefer PhysX ``RigidBodyView`` transforms (tensor API). -2. For any body not covered by that view, read poses via ``XformPrimView`` on the USD stage. -3. Merge and write poses into Newton ``body_q`` with Warp kernels. +1. Reads transforms from PhysX ``RigidBodyView`` (fast tensor API) +2. Falls back to ``XformPrimView`` for bodies not covered by the rigid body view +3. Converts and writes merged poses into the Newton state via Warp kernels Newton Scene Data Provider -------------------------- -When Newton is the active physics backend, the provider returns the **NewtonManager** model and state handles. +When Newton is the active physics backend, the provider **delegates directly to the Newton +manager** — no building or syncing required. Newton already owns the authoritative model and +state. -When a Kit visualizer is active, the provider can **sync transforms to the USD stage** for Kit rendering. -For Rerun or Viser without Kit, that USD sync is not needed and is skipped. +The only additional work is **optional USD sync**: when an Omniverse Kit visualizer is active, +the provider syncs Newton transforms to the USD stage so Kit can render them. For Newton-only +or Rerun/Viser visualizers, this sync is skipped. Data Requirements ----------------- diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 63eec8b92934..8fe7fadbad91 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -366,10 +366,10 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: - Multiple visualizers can be specified as a comma-delimited list: ``--viz rerun,newton,viser``. - * ``max_visible_envs`` (int | None): Optional global override for enabling partial visualizaiton by - capping the number of environments show in the visualizers, which can improve performance. + * ``max_visible_envs`` (int | None): Optional global override for enabling partial visualizaiton by + capping the number of environments show in the visualizers, which can improve performance. More partial visualization configuration fields are available in the VisualizerCfg class. - + .. _`WebRTC`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client Args: @@ -523,9 +523,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "--max_visible_envs", type=int, default=argparse.SUPPRESS, - help=( - "When set, caps the nums of envs shown in the launched visualizers to improve performance." - ), + help=("When set, caps the nums of envs shown in the launched visualizers to improve performance."), ) # special flag for backwards compatibility diff --git a/source/isaaclab/isaaclab/physics/scene_data_requirements.py b/source/isaaclab/isaaclab/physics/scene_data_requirements.py index 49947342fa9b..616592d9b1e0 100644 --- a/source/isaaclab/isaaclab/physics/scene_data_requirements.py +++ b/source/isaaclab/isaaclab/physics/scene_data_requirements.py @@ -26,10 +26,10 @@ class SceneDataRequirement: @dataclass(frozen=True) class VisualizerPrebuiltArtifacts: - """Newton model/state and rigid-body paths produced during scene clone setup. + """Prebuilt model/state payload shared from scene setup to providers. - The PhysX scene data provider reads this from the simulation context when Newton - visualizers are active. + This gets produced during clone-time visualizer prebuild and then read by + scene data providers as a fast path (instead of rebuilding from USD). """ model: Any diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index e5a5baa20d18..d6107d098524 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -157,11 +157,7 @@ def _compute_visualized_env_ids(self) -> list[int] | None: max_visible = getattr(cfg, "max_visible_envs", None) # Random subset only for cap-only mode: needs a cap and no explicit indices (see VisualizerCfg). - if ( - max_visible is not None - and getattr(cfg, "randomly_sample_visible_envs", True) - and int(max_visible) >= 0 - ): + if max_visible is not None and getattr(cfg, "randomly_sample_visible_envs", True) and int(max_visible) >= 0: k = min(int(max_visible), num_envs) # k == 0: sample(range(n), 0) is []; contiguous resolver used the same convention. return sorted(random.sample(range(num_envs), k)) diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index f9504ee43afc..55348ecf6361 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -41,7 +41,12 @@ class VisualizerCfg: """Initial camera look-at point (x, y, z) in world coordinates.""" cam_source: Literal["cfg", "prim_path"] = "cfg" - """Camera source mode: 'cfg' uses eye/lookat, 'prim_path' follows a camera prim.""" + """Camera source mode: 'cfg' uses eye/lookat, 'prim_path' follows a camera prim. + + For the Kit visualizer, ``cfg`` also means simulation-driven camera updates from + :class:`~isaaclab.envs.common.ViewerCfg` (e.g. via :class:`ViewportCameraController`) are not applied, + so set ``eye`` / ``lookat`` on the visualizer config for the dedicated viewport pose. + """ cam_prim_path: str = "/World/envs/env_0/Camera" """Absolute USD path to a camera prim when cam_source='prim_path'.""" @@ -58,7 +63,7 @@ class VisualizerCfg: randomly_sample_visible_envs: bool = True """If ``max_visible_envs`` is provided, the selected visible envs are randomly sampled. - + * Note ``visible_env_indices`` overrides this field. """ diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py index 8f3e004e4cb5..2e285b922cc8 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py @@ -183,8 +183,7 @@ def _load_newton_model_from_prebuilt_artifact(self) -> None: if model is None or state is None: self._last_newton_model_build_source = "missing" logger.error( - "[PhysxSceneDataProvider] Prebuilt artifact is missing model or state;" - " cannot sync PhysX to Newton." + "[PhysxSceneDataProvider] Prebuilt artifact is missing model or state; cannot sync PhysX to Newton." ) self._clear_newton_model_state() return diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index 3e3661b90c95..f7e87e416d41 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -211,8 +211,9 @@ def launch_simulation( app_launcher = AppLauncher(launcher_args) close_fn = app_launcher.app.close elif visualizer_types: - # Newton path without Kit: AppLauncher is skipped — persist the same visualizer CLI - # settings (types, max_visible_envs CLI override) that AppLauncher would write. + # Newton path without Kit: AppLauncher is skipped, so manually store the visualizer + # selection in SettingsManager (works in standalone mode via plain dict) so that + # SimulationContext._get_cli_visualizer_types() can find it. from isaaclab.app.app_launcher import sync_visualizer_cli_settings_to_carb disable_all = "none" in visualizer_types diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 6e3fd2c69037..6f3381e46005 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -11,7 +11,7 @@ import logging from typing import TYPE_CHECKING -from pxr import UsdGeom +from pxr import Usd, UsdGeom, Vt from isaaclab.app.settings_manager import get_settings_manager from isaaclab.visualizers.base_visualizer import BaseVisualizer @@ -47,6 +47,8 @@ def __init__(self, cfg: KitVisualizerCfg): self._sim_time = 0.0 self._step_counter = 0 self._hidden_env_visibilities: dict[str, str] = {} + # PointInstancer prim path -> (had authored invisibleIds, previous value) for partial viz restore. + self._point_instancer_invisible_ids_backup: dict[str, tuple[bool, object]] = {} self._runtime_headless = bool(cfg.headless) # USD path for the viewport's active camera, refreshed after setup (used by CI/tests). self._controlled_camera_path: str | None = None @@ -186,10 +188,17 @@ def set_camera_view( ) -> None: """Set active viewport camera eye/target. + When :attr:`self.cfg.cam_source` is ``"cfg"``, this is a no-op: the pose comes only from + :attr:`self.cfg.eye` / :attr:`self.cfg.lookat` (applied in :meth:`_setup_viewport`). Otherwise + :class:`~isaaclab.sim.simulation_context.SimulationContext` and :class:`ViewportCameraController` + would overwrite that pose with :class:`~isaaclab.envs.common.ViewerCfg`-driven views. + Args: eye: Camera eye position. target: Camera look-at target. """ + if self.cfg.cam_source == "cfg": + return if not self._is_initialized: logger.debug("[KitVisualizer] set_camera_view() ignored because visualizer is not initialized.") return @@ -383,10 +392,50 @@ def _apply_env_visibility(self, usd_stage, metadata: dict, visible_env_ids: list self._hidden_env_visibilities[env_path] = prev attr.Set(UsdGeom.Tokens.invisible) + self._apply_visual_point_instancer_visibility(usd_stage, num_envs, visible) + + def _apply_visual_point_instancer_visibility(self, usd_stage, num_envs: int, visible_env_ids: set[int]) -> None: + """Set ``PointInstancer.invisibleIds`` for `/Visuals` markers with one instance per env (e.g. velocity arrows).""" + self._point_instancer_invisible_ids_backup.clear() + hidden = [i for i in range(num_envs) if i not in visible_env_ids] + vt_hidden = Vt.Int64Array([int(i) for i in hidden]) + for root_path in ("/Visuals", "/World/Visuals"): + root_prim = usd_stage.GetPrimAtPath(root_path) + if not root_prim.IsValid(): + continue + for prim in Usd.PrimRange(root_prim): + if not prim.IsA(UsdGeom.PointInstancer): + continue + pi = UsdGeom.PointInstancer(prim) + n = self._point_instancer_instance_count(pi) + if n is None or n != num_envs: + continue + path_str = prim.GetPath().pathString + inv_attr = pi.GetInvisibleIdsAttr() + was_authored = inv_attr.HasAuthoredValue() + prev = inv_attr.Get() if was_authored else None + self._point_instancer_invisible_ids_backup[path_str] = (was_authored, prev) + inv_attr.Set(vt_hidden) + + @staticmethod + def _point_instancer_instance_count(pi: UsdGeom.PointInstancer) -> int | None: + """Return instance count from the first authored per-instance array, if any.""" + for attr in ( + pi.GetPositionsAttr(), + pi.GetScalesAttr(), + pi.GetOrientationsAttr(), + pi.GetProtoIndicesAttr(), + ): + if not attr.HasAuthoredValue(): + continue + val = attr.Get() + if val is None: + continue + return len(val) + return None + def _restore_env_visibility(self) -> None: - """Restore environment visibilities modified by env filtering.""" - if not self._hidden_env_visibilities: - return + """Restore environment visibilities and PointInstancer ``invisibleIds`` from partial viz.""" usd_stage = self._scene_data_provider.get_usd_stage() if self._scene_data_provider else None if usd_stage is None: return @@ -399,3 +448,14 @@ def _restore_env_visibility(self) -> None: continue imageable.GetVisibilityAttr().Set(prev) self._hidden_env_visibilities.clear() + + for path_str, (was_authored, prev) in self._point_instancer_invisible_ids_backup.items(): + prim = usd_stage.GetPrimAtPath(path_str) + if not prim.IsValid() or not prim.IsA(UsdGeom.PointInstancer): + continue + inv_attr = UsdGeom.PointInstancer(prim).GetInvisibleIdsAttr() + if not was_authored: + inv_attr.Clear() + else: + inv_attr.Set(prev) + self._point_instancer_invisible_ids_backup.clear() diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py index 342be3fc2c6f..1fde91f8aed7 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py @@ -24,7 +24,7 @@ class KitVisualizerCfg(VisualizerCfg): If ``None``, a default name (``"Visualizer Viewport"``) is used. """ - create_viewport: bool = False + create_viewport: bool = True """If ``True``, create a new viewport window; if ``False``, use the active viewport window.""" headless: bool = False diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py index 4f34e6335ba2..8c8bb0bed9d8 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py @@ -285,7 +285,6 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: metadata = scene_data_provider.get_metadata() num_envs = int(metadata.get("num_envs", 0)) self._env_ids = self._compute_visualized_env_ids() - # Full model + ViewerBase.set_visible_worlds() (Newton PR #2267); avoids cloning a reduced model. self._model = scene_data_provider.get_newton_model() self._state = scene_data_provider.get_newton_state() From df2644ced05af5da80dd9ed090f26cc0a4805e98 Mon Sep 17 00:00:00 2001 From: Piotr Barejko Date: Tue, 21 Apr 2026 16:31:31 -0700 Subject: [PATCH 18/37] Revert "Add NVTX instrumentation to Newton Warp Renderer (#5294)" (#5348) We don't need custom instrumentation. Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Breaking change (existing functionality will not work without user modification) - Documentation update ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../renderers/newton_warp_renderer.py | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py index a870f0ae0530..22f8acb47a93 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -7,7 +7,6 @@ from __future__ import annotations -import functools import logging import weakref from dataclasses import dataclass @@ -30,36 +29,6 @@ logger = logging.getLogger(__name__) -try: - import nvtx - - _nvtx_domain = nvtx.Domain("NewtonWarpRenderer") - - def _nvtx_range(message: str, color: str | None = None): - """Decorator that wraps a function in a Domain.push_range/pop_range pair.""" - attrs = _nvtx_domain.get_event_attributes(message=message, color=color) - - def decorator(fn): - @functools.wraps(fn) - def wrapper(*args, **kwargs): - _nvtx_domain.push_range(attrs) - try: - return fn(*args, **kwargs) - finally: - _nvtx_domain.pop_range() - - return wrapper - - return decorator - -except ImportError: - - def _nvtx_range(message: str, color: str | None = None): - def decorator(fn): - return fn - - return decorator - class RenderData: class OutputNames: @@ -230,7 +199,6 @@ def set_outputs(self, render_data: RenderData, output_data: dict[str, torch.Tens """Store output buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.set_outputs`.""" render_data.set_outputs(output_data) - @_nvtx_range("update_transforms", color="blue") def update_transforms(self): """Sync Newton scene state before rendering. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_transforms`.""" @@ -243,7 +211,6 @@ def update_camera( See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_camera`.""" render_data.update(positions, orientations, intrinsics) - @_nvtx_range("render", color="green") def render(self, render_data: RenderData): """Render and write to output buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.render`.""" self.newton_sensor.update( @@ -259,7 +226,6 @@ def render(self, render_data: RenderData): clear_data=newton.sensors.SensorTiledCamera.ClearData(clear_color=0xFFEEEEEE), ) - @_nvtx_range("read_output", color="orange") def read_output(self, render_data: RenderData, camera_data: CameraData) -> None: """Copy rendered outputs to the camera data buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.read_output`.""" From 9aa3f703fb5bf8483d2a2673c1876c2e9b295c19 Mon Sep 17 00:00:00 2001 From: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:18:36 -0700 Subject: [PATCH 19/37] Fixes installation; updates installation docs (#5314) Fix documentation bugs, improve installation guides, add install_ci test coverage, and decouple isaaclab_physx from isaaclab_newton. [Lines 24, 25, 27 in Isaac Lab Release Tracker] - Fix incorrect variable, link, comment syntax, and missing pip index URL in installation docs - Rewrite cloud installation guide for Isaac Automator v4 - Improve installation docs consistency: standard venv name, aarch64 PyTorch tab, rl_games clarification - Add install_ci test for Newton-only and PhysX-only installation paths - Decouple isaaclab_physx from hard isaaclab_newton dependency: remove NewtonSceneDataProvider from the physx .pyi stub, gate the import behind an availability check with a warning, and add newton as an optional dependency in isaaclab_physx setup.py - Add automatic docker/native environment detection with marker-based test skipping ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) - Documentation update ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../setup/installation/cloud_installation.rst | 214 ++++++++---------- .../include/src_python_virtual_env.rst | 4 +- .../isaaclab_pip_installation.rst | 9 +- .../installation/source_installation.rst | 2 +- docs/source/setup/quick_installation.rst | 4 +- docs/source/setup/quickstart.rst | 26 ++- .../isaaclab/isaaclab/cli/commands/install.py | 6 - source/isaaclab/test/install_ci/conftest.py | 28 ++- source/isaaclab/test/install_ci/pytest.ini | 4 +- .../install_ci/test_environment_markers.py | 77 +++++++ .../install_ci/test_isaaclabx_i_newton.py | 61 +++++ .../test/install_ci/test_isaaclabx_i_physx.py | 61 +++++ .../install_ci/test_isaaclabx_uv_smoke.py | 10 +- source/isaaclab/test/install_ci/utils.py | 72 ++++++ source/isaaclab_newton/setup.py | 6 +- .../scene_data_providers/__init__.pyi | 2 - .../physx_scene_data_provider.py | 1 + source/isaaclab_physx/setup.py | 13 +- 18 files changed, 436 insertions(+), 164 deletions(-) create mode 100644 source/isaaclab/test/install_ci/test_environment_markers.py create mode 100644 source/isaaclab/test/install_ci/test_isaaclabx_i_newton.py create mode 100644 source/isaaclab/test/install_ci/test_isaaclabx_i_physx.py diff --git a/docs/source/setup/installation/cloud_installation.rst b/docs/source/setup/installation/cloud_installation.rst index cadecfce1c43..b6d9137680a3 100644 --- a/docs/source/setup/installation/cloud_installation.rst +++ b/docs/source/setup/installation/cloud_installation.rst @@ -2,15 +2,15 @@ Cloud Deployment ================ Isaac Lab can be run in various cloud infrastructures with the use of -`Isaac Automator `__. +`Isaac Automator `__ (v4). -Isaac Automator allows for quick deployment of Isaac Sim and Isaac Lab onto -the public clouds (AWS, GCP, Azure, and Alibaba Cloud are currently supported). -The result is a fully configured remote desktop cloud workstation, which can -be used for development and testing of Isaac Lab within minutes and on a budget. -Isaac Automator supports variety of GPU instances and stop-start functionality -to save on cloud costs and a variety of tools to aid the workflow -(such as uploading and downloading data, autorun, deployment management, etc). +Isaac Automator allows quick deployment of Isaac Sim, Isaac Lab, and Isaac Lab Arena +onto public clouds (AWS, GCP, Azure, and Alibaba Cloud are currently supported). +The result is a fully configured remote desktop cloud workstation (Isaac Workstation), +which can be used for development and testing of Isaac Lab within minutes and on a budget. +Isaac Automator supports a variety of GPU instances and stop/start functionality +to save on cloud costs, and provides tools to aid the workflow +(uploading and downloading data, autorun scripts, deployment management, etc.). System Requirements @@ -19,17 +19,16 @@ System Requirements Isaac Automator requires having ``docker`` pre-installed on the system. * To install Docker, please follow the instructions for your operating system on the - `Docker website`_. A minimum version of 26.0.0 for Docker Engine and 2.25.0 for Docker - compose are required to work with Isaac Automator. + `Docker website`_. * Follow the post-installation steps for Docker on the `post-installation steps`_ page. These steps allow you to run Docker without using ``sudo``. Installing Isaac Automator --------------------------- +--------------------------- -For the most update-to-date and complete installation instructions, please refer to -`Isaac Automator `__. +For the most up-to-date and complete installation instructions, please refer to +the `Isaac Automator README `__. To use Isaac Automator, first clone the repo: @@ -48,37 +47,15 @@ To use Isaac Automator, first clone the repo: git clone git@github.com:isaac-sim/IsaacAutomator.git -Isaac Automator requires obtaining a NGC API key. - -* Get access to the `Isaac Sim container`_ by joining the NVIDIA Developer Program credentials. -* Generate your `NGC API key`_ to access locked container images from NVIDIA GPU Cloud (NGC). - - * This step requires you to create an NGC account if you do not already have one. - * Once you have your generated API key, you need to log in to NGC - from the terminal. - - .. code:: bash - - docker login nvcr.io - - * For the username, enter ``$oauthtoken`` exactly as shown. It is a special username that is used to - authenticate with NGC. - - .. code:: text - - Username: $oauthtoken - Password: - - -Building the container +Building the Container ---------------------- -To run Isaac Automator, first build the Isaac Automator container: +Build the Isaac Automator container: .. tab-set:: :sync-group: os - .. tab-item:: :icon:`fa-brands fa-linux` Linux + .. tab-item:: :icon:`fa-brands fa-linux` Linux / macOS :sync: linux .. code-block:: bash @@ -90,149 +67,138 @@ To run Isaac Automator, first build the Isaac Automator container: .. code-block:: batch - docker build --platform linux/x86_64 -t isa . + docker build --platform linux/x86_64 -t isaac_automator . +This will build the Isaac Automator container and tag it as ``isaac_automator``. -This will build the Isaac Automator container and tag it as ``isa``. - -Running the Automator Commands +Deploying an Isaac Workstation ------------------------------ -First, enter the Automator container: - .. tab-set:: :sync-group: os - .. tab-item:: :icon:`fa-brands fa-linux` Linux + .. tab-item:: :icon:`fa-brands fa-linux` Linux / macOS :sync: linux + Enter the Automator container and run the deployment command: + .. code-block:: bash ./run + # inside container: + ./deploy-aws + + Alternatively, run it in one step: + + .. code-block:: bash + + ./run ./deploy-aws .. tab-item:: :icon:`fa-brands fa-windows` Windows :sync: windows .. code-block:: batch - docker run --platform linux/x86_64 -it --rm -v .:/app isa bash + docker run --platform linux/x86_64 -it --rm -v .:/app isaac_automator bash + :: inside container: + ./deploy-aws -Next, run the deployment script for your preferred cloud: +Replace ``deploy-aws`` with ``deploy-gcp``, ``deploy-azure``, or ``deploy-alicloud`` +for other cloud providers. .. note:: - The ``--isaaclab`` flag is used to specify the version of Isaac Lab to deploy. - The ``v3.0.0`` tag is the latest release of Isaac Lab. - -.. tab-set:: - :sync-group: cloud - - .. tab-item:: AWS - :sync: aws - - .. code-block:: bash - - ./deploy-aws --isaaclab v3.0.0 - - .. tab-item:: Azure - :sync: azure + The ``--isaaclab`` and ``--isaacsim`` flags accept any valid Git reference + to specify the version to deploy. Use ``--isaaclab no`` or ``--isaacsim no`` + to skip installation of the respective component. - .. code-block:: bash + .. code-block:: bash - ./deploy-azure --isaaclab v3.0.0 + ./deploy-aws --isaaclab v3.0.0 --isaacsim main - .. tab-item:: GCP - :sync: gcp +On the first run (or when credentials expire), you will be prompted to enter +your cloud credentials. Credentials are stored in ``state/`` and persist +across container restarts. Run ``./deploy- --help`` to see all available +options. - .. code-block:: bash +Key deployment options: - ./deploy-gcp --isaaclab v3.0.0 - - .. tab-item:: Alibaba Cloud - :sync: alicloud - - .. code-block:: bash +- ``--instance-type`` -- Cloud VM instance type. +- ``--isaacsim`` / ``--isaaclab`` / ``--isaaclab-arena`` -- Git ref for the version + to install, or ``no`` to skip. +- ``--existing`` -- What to do if a deployment already exists: ``ask`` (default), + ``repair``, ``modify``, ``replace``, or ``run_ansible``. +- ``--from-image`` -- Deploy from a pre-built VM image for faster provisioning + (AWS only at this time). - ./deploy-alicloud --isaaclab v3.0.0 +Connecting to the Isaac Workstation +----------------------------------- -Follow the prompts for entering information regarding the environment setup and credentials. -Once successful, instructions for connecting to the cloud instance will be available -in the terminal. The deployed Isaac Sim instances can be accessed via: +Deployed Isaac Workstations can be accessed via: -- SSH -- noVCN (browser-based VNC client) -- NoMachine (remote desktop client) +- **SSH**: ``./ssh `` +- **noVNC** (browser-based): ``./novnc `` +- **NoMachine** (remote desktop client) -Look for the connection instructions at the end of the deployment command output. -Additionally, this info is saved in ``state//info.txt`` file. - -For details on the credentials and setup required for each cloud, please visit the -`Isaac Automator `__ -page for more instructions. +Connection instructions are displayed at the end of the deployment command +output and saved in ``state//info.txt``. Running Isaac Lab on the Cloud ------------------------------ -Once connected to the cloud instance, the desktop will have an icon showing ``isaaclab.sh``. -Launch the ``isaaclab.sh`` executable, which will open a new Terminal. Within the terminal, -Isaac Lab commands can be executed in the same way as running locally. - -For example: - -.. tab-set:: - :sync-group: os - - .. tab-item:: :icon:`fa-brands fa-linux` Linux - :sync: linux +Isaac Lab is installed from source on the deployed workstation at ``~/IsaacLab``. +To run Isaac Lab commands, open a terminal on the workstation: - .. code-block:: bash +.. code-block:: bash - ./isaaclab.sh -p scripts/reinforcement_learning/rl_games/train.py --task=Isaac-Cartpole-v0 + ~/IsaacLab/isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task=Isaac-Cartpole-Direct-v0 --headless - .. tab-item:: :icon:`fa-brands fa-windows` Windows - :sync: windows - .. code-block:: batch +Pausing and Resuming +-------------------- - isaaclab.bat -p scripts/reinforcement_learning/rl_games/train.py --task=Isaac-Cartpole-v0 +You can stop and restart instances to save on cloud costs: +.. code-block:: bash -Destroying a Deployment ------------------------ + # inside the Automator container: + ./stop + ./start -To save costs, deployments can be destroyed when not being used. -This can be done from within the Automator container. +Use ``./start --quick`` to skip full Ansible provisioning +and only run the autorun script. -Enter the Automator container with the command described in the previous section: -.. tab-set:: - :sync-group: os +Uploading and Downloading Data +------------------------------ - .. tab-item:: :icon:`fa-brands fa-linux` Linux - :sync: linux +.. code-block:: bash - .. code-block:: bash + # upload local uploads/ folder to the instance + ./upload - ./run + # download results from the instance to local results/ folder + ./download - .. tab-item:: :icon:`fa-brands fa-windows` Windows - :sync: windows - .. code-block:: batch +Destroying a Deployment +----------------------- - docker run --platform linux/x86_64 -it --rm -v .:/app isa bash +To save costs, destroy deployments when no longer needed: +.. code-block:: bash -To destroy a deployment, run the following command from within the container: + # inside the Automator container: + ./destroy -.. code:: bash +.. note:: - ./destroy + Deployment metadata is stored in the ``state/`` directory. Do not delete this + directory, as it is required for managing deployments. -.. _`Docker website`: https://docs.docker.com/desktop/install/linux-install/ +.. _`Docker website`: https://docs.docker.com/engine/install/ .. _`post-installation steps`: https://docs.docker.com/engine/install/linux-postinstall/ -.. _`Isaac Sim container`: https://catalog.ngc.nvidia.com/orgs/nvidia/containers/isaac-sim -.. _`NGC API key`: https://docs.nvidia.com/ngc/gpu-cloud/ngc-user-guide/index.html#generating-api-key diff --git a/docs/source/setup/installation/include/src_python_virtual_env.rst b/docs/source/setup/installation/include/src_python_virtual_env.rst index 617e29ace75a..4ca31fafb17a 100644 --- a/docs/source/setup/installation/include/src_python_virtual_env.rst +++ b/docs/source/setup/installation/include/src_python_virtual_env.rst @@ -62,7 +62,7 @@ instead of *./isaaclab.sh -p* or *isaaclab.bat -p*. .. warning:: Windows support for UV is currently unavailable. Please check - `issue #3483 `_ to track progress. + `issue #3438 `_ to track progress. .. tab-item:: Conda Environment @@ -103,7 +103,7 @@ instead of *./isaaclab.sh -p* or *isaaclab.bat -p*. .. code:: batch :: Activate environment - conda activate env_isaaclab # or "conda activate my_env" + conda activate env_isaaclab :: or "conda activate my_env" Once you are in the virtual environment, you do not need to use ``./isaaclab.sh -p`` or ``isaaclab.bat -p`` to run python scripts. You can use the default python executable in your diff --git a/docs/source/setup/installation/isaaclab_pip_installation.rst b/docs/source/setup/installation/isaaclab_pip_installation.rst index d3070fd51f50..1d98f1536971 100644 --- a/docs/source/setup/installation/isaaclab_pip_installation.rst +++ b/docs/source/setup/installation/isaaclab_pip_installation.rst @@ -61,7 +61,7 @@ Isaac Lab sub-packages: uv pip install isaaclab==3.0.0 # specific version # Isaac Lab + Isaac Sim - uv pip install "isaaclab[isaacsim]" --index-strategy unsafe-best-match --prerelease=allow + uv pip install "isaaclab[isaacsim]" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow # Isaac Lab + specific sub-package(s) # Note: flags above are only needed when installing the isaacsim extra @@ -69,7 +69,7 @@ Isaac Lab sub-packages: uv pip install "isaaclab[rl,tasks]" # Isaac Lab + Isaac Sim + all sub-packages - uv pip install "isaaclab[isaacsim,all]" --index-strategy unsafe-best-match --prerelease=allow + uv pip install "isaaclab[isaacsim,all]" --extra-index-url https://pypi.nvidia.com --index-strategy unsafe-best-match --prerelease=allow .. tab-item:: pip @@ -170,8 +170,9 @@ Installing dependencies When using a conda environment, the preload is set up via the conda activation hook. -- If you want to use ``rl_games`` for training and inferencing, install - its Python 3.11+ enabled fork: +- If you want to use ``rl_games`` for training and inferencing **and did not + install the** ``rl`` **extra above**, install its Python 3.11+ enabled fork + manually: .. code-block:: none diff --git a/docs/source/setup/installation/source_installation.rst b/docs/source/setup/installation/source_installation.rst index c697c1dd2054..ce575e48bc7b 100644 --- a/docs/source/setup/installation/source_installation.rst +++ b/docs/source/setup/installation/source_installation.rst @@ -78,7 +78,7 @@ variables to your terminal for the remaining of the installation instructions: .. code:: bash # Isaac Sim root directory - export ISAACSIM_PATH="${pwd}/_build/linux-x86_64/release" + export ISAACSIM_PATH="${PWD}/_build/linux-x86_64/release" # Isaac Sim python executable export ISAACSIM_PYTHON_EXE="${ISAACSIM_PATH}/python.sh" diff --git a/docs/source/setup/quick_installation.rst b/docs/source/setup/quick_installation.rst index 3a4b60cb3317..36ed9208fd39 100644 --- a/docs/source/setup/quick_installation.rst +++ b/docs/source/setup/quick_installation.rst @@ -15,8 +15,8 @@ Quick Installation cd IsaacLab # Create environment and install - uv venv .venv --python 3.12 - source .venv/bin/activate + uv venv --python 3.12 --seed env_isaaclab + source env_isaaclab/bin/activate ./isaaclab.sh -i # Run training (Newton backend, 16 envs) diff --git a/docs/source/setup/quickstart.rst b/docs/source/setup/quickstart.rst index 4ebdcd024046..39e7ec4cb932 100644 --- a/docs/source/setup/quickstart.rst +++ b/docs/source/setup/quickstart.rst @@ -68,11 +68,31 @@ package manager. To begin, create a virtual environment: conda activate env_isaaclab -Next, install a CUDA-enabled PyTorch build. +Next, install a CUDA-enabled PyTorch build that matches your system architecture. - .. code-block:: bash +.. tab-set:: + :sync-group: pip-platform + + .. tab-item:: :icon:`fa-brands fa-linux` Linux (x86_64) + :sync: linux-x86_64 + + .. code-block:: bash + + uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + + .. tab-item:: :icon:`fa-brands fa-windows` Windows (x86_64) + :sync: windows-x86_64 + + .. code-block:: bash + + uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + + .. tab-item:: :icon:`fa-brands fa-linux` Linux (aarch64) + :sync: linux-aarch64 + + .. code-block:: bash - uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu128 + uv pip install -U torch==2.10.0 torchvision==0.25.0 --index-url https://download.pytorch.org/whl/cu130 Before we can install Isaac Sim, we need to make sure pip is updated. To update pip, run diff --git a/source/isaaclab/isaaclab/cli/commands/install.py b/source/isaaclab/isaaclab/cli/commands/install.py index 1ee0c8cd0174..53cf4a799fb1 100644 --- a/source/isaaclab/isaaclab/cli/commands/install.py +++ b/source/isaaclab/isaaclab/cli/commands/install.py @@ -503,12 +503,6 @@ def command_install(install_type: str = "all") -> None: if name == "newton" and "isaaclab_visualizers" not in isaaclab_submodules: isaaclab_submodules.append("isaaclab_visualizers") submodule_extras["isaaclab_visualizers"] = "[newton]" - # newton and physx are tightly coupled; always install both together. - # todo: remove once we move to UV and pyproject.toml-based packaging - if name == "newton" and "isaaclab_physx" not in isaaclab_submodules: - isaaclab_submodules.append("isaaclab_physx") - if name == "physx" and "isaaclab_newton" not in isaaclab_submodules: - isaaclab_submodules.append("isaaclab_newton") else: valid = sorted(VALID_ISAACLAB_SUBMODULES) + sorted(VALID_RL_FRAMEWORKS) + ["isaacsim"] print_warning(f"Unknown Isaac Lab submodule '{name}'. Valid values: {', '.join(valid)}. Skipping.") diff --git a/source/isaaclab/test/install_ci/conftest.py b/source/isaaclab/test/install_ci/conftest.py index 226af44a65f6..c4bbc94ab200 100644 --- a/source/isaaclab/test/install_ci/conftest.py +++ b/source/isaaclab/test/install_ci/conftest.py @@ -20,6 +20,8 @@ _CYAN_BRIGHT = "\033[96m" _RESET = "\033[0m" +_EXECUTION_ENVIRONMENT_KEY = pytest.StashKey[_utils.ExecutionEnvironment]() + # Fixtures @@ -79,16 +81,26 @@ def wheel_path() -> Path | None: def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line("markers", "bug: bug-regression tests (use bug id as argument)") config.addinivalue_line("markers", "gpu: tests that require a GPU") - config.addinivalue_line("markers", "docker_only: tests that only run inside Docker") - config.addinivalue_line("markers", "needs_network: tests that require network access") + config.addinivalue_line("markers", "docker: tests that only run inside Docker") + config.addinivalue_line("markers", "native: tests that only run natively (not in Docker)") config.addinivalue_line("markers", "slow: tests that take a long time") config.addinivalue_line("markers", "uv: tests that require the uv package manager") + try: + config.stash[_EXECUTION_ENVIRONMENT_KEY] = _utils.detect_execution_environment() + except ValueError as exc: + raise pytest.UsageError(str(exc)) from exc + # Enable real-time output when pytest capture is disabled (-s) capture = config.getoption("capture", default="fd") _utils.stream_output = capture == "no" +def pytest_report_header(config: pytest.Config) -> str: + """Show the detected install_ci execution environment in the test header.""" + return f"install_ci execution environment: {config.stash[_EXECUTION_ENVIRONMENT_KEY]}" + + def pytest_runtest_logreport(report: pytest.TestReport) -> None: """Print a newline after the PASSED/FAILED/SKIPPED result.""" if report.when == "call" or (report.when == "setup" and report.skipped): @@ -97,11 +109,12 @@ def pytest_runtest_logreport(report: pytest.TestReport) -> None: @pytest.hookimpl(tryfirst=True) def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: - """Dynamically map marker arguments from `@pytest.mark.bug("...")` to standalone markers. + """Map dynamic bug markers and skip items with mismatched env markers. This allows filtering by bug ID natively in pytest: `-m "nvbugs_5968136"` instead of the (unsupported natively) `-m "bug('nvbugs_5968136')"`. """ + execution_environment = config.stash[_EXECUTION_ENVIRONMENT_KEY] known_bugs = set() for item in items: for mark in item.iter_markers(name="bug"): @@ -117,3 +130,12 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item for arg in mark.args: if isinstance(arg, str): item.add_marker(arg) + + marker_names = {mark.name for mark in item.iter_markers()} + try: + skip_reason = _utils.get_execution_environment_skip_reason(marker_names, execution_environment) + except ValueError as exc: + raise pytest.UsageError(f"{item.nodeid}: {exc}") from exc + + if skip_reason: + item.add_marker(pytest.mark.skip(reason=skip_reason)) diff --git a/source/isaaclab/test/install_ci/pytest.ini b/source/isaaclab/test/install_ci/pytest.ini index 26abb3f86add..67eac9b17ca7 100644 --- a/source/isaaclab/test/install_ci/pytest.ini +++ b/source/isaaclab/test/install_ci/pytest.ini @@ -8,8 +8,8 @@ python_files = markers = bug: bug-regression tests (use bug id as argument) gpu: tests that require a GPU - docker_only: tests that only run inside Docker - needs_network: tests that require network access + docker: tests that only run inside Docker + native: tests that only run natively (not in Docker) slow: tests that take a long time uv: tests that require the uv package manager timeout = 1200 diff --git a/source/isaaclab/test/install_ci/test_environment_markers.py b/source/isaaclab/test/install_ci/test_environment_markers.py new file mode 100644 index 000000000000..c002353b5c3e --- /dev/null +++ b/source/isaaclab/test/install_ci/test_environment_markers.py @@ -0,0 +1,77 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for install_ci execution-environment marker handling.""" + +from __future__ import annotations + +import pytest +from utils import ( + detect_execution_environment, + get_execution_environment_skip_reason, +) + + +class TestDetectExecutionEnvironment: + """Tests for detect_execution_environment().""" + + def test_uses_override(self, tmp_path): + environment = detect_execution_environment( + environ={"ISAACLAB_INSTALL_CI_ENV": "docker"}, + filesystem_root=tmp_path, + ) + + assert environment == "docker" + + def test_detects_marker_file(self, tmp_path): + (tmp_path / ".dockerenv").touch() + + environment = detect_execution_environment(environ={}, filesystem_root=tmp_path) + + assert environment == "docker" + + def test_detects_cgroup_hint(self, tmp_path): + cgroup_path = tmp_path / "proc" / "self" + cgroup_path.mkdir(parents=True) + (cgroup_path / "cgroup").write_text("0::/docker/container-id\n", encoding="utf-8") + + environment = detect_execution_environment(environ={}, filesystem_root=tmp_path) + + assert environment == "docker" + + def test_defaults_to_native(self, tmp_path): + environment = detect_execution_environment(environ={}, filesystem_root=tmp_path) + + assert environment == "native" + + def test_rejects_invalid_override(self, tmp_path): + with pytest.raises(ValueError, match="ISAACLAB_INSTALL_CI_ENV"): + detect_execution_environment( + environ={"ISAACLAB_INSTALL_CI_ENV": "virtual-machine"}, + filesystem_root=tmp_path, + ) + + +class TestGetExecutionEnvironmentSkipReason: + """Tests for get_execution_environment_skip_reason().""" + + @pytest.mark.parametrize( + ("marker_names", "execution_environment", "expected_reason"), + [ + ({"docker"}, "native", "requires Docker execution environment, detected native"), + ({"native"}, "docker", "requires native execution environment, detected docker"), + ({"docker"}, "docker", None), + ({"native"}, "native", None), + (set(), "native", None), + ], + ) + def test_skip_reason(self, marker_names, execution_environment, expected_reason): + skip_reason = get_execution_environment_skip_reason(marker_names, execution_environment) + + assert skip_reason == expected_reason + + def test_rejects_conflicting_markers(self): + with pytest.raises(ValueError, match="docker"): + get_execution_environment_skip_reason({"docker", "native"}, "native") diff --git a/source/isaaclab/test/install_ci/test_isaaclabx_i_newton.py b/source/isaaclab/test/install_ci/test_isaaclabx_i_newton.py new file mode 100644 index 000000000000..4f7740dfe6fd --- /dev/null +++ b/source/isaaclab/test/install_ci/test_isaaclabx_i_newton.py @@ -0,0 +1,61 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Test installing isaaclab_newton and running its test suite.""" + +from __future__ import annotations + +import shutil + +import pytest +from utils import UV_Mixin, find_isaaclab_root + + +class Test_Install_Newton(UV_Mixin): + """Install ./isaaclab.sh -i newton and run the isaaclab_newton test suite.""" + + @classmethod + def setup_class(cls): + # check if uv is available + if not shutil.which("uv"): + pytest.skip("uv is not available") + + # check if isaacsim is importable + # or "_isaac_sim" link is present + try: + import isaacsim # noqa: F401 + except ImportError: + print("[DEBUG] Module isaacsim is not importable") + isaac_sim_link = find_isaaclab_root() / "_isaac_sim" + if not isaac_sim_link.exists(): + print(f'[DEBUG] Link "{isaac_sim_link}" does not exist') + pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") + + @pytest.mark.uv + @pytest.mark.gpu + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(3600) + def test_install_newton_and_run_tests(self, isaaclab_root): + """Install newton extension and run the isaaclab_newton test suite.""" + + try: + self.create_uv_env(isaaclab_root) + + # ./isaaclab.sh -i newton + result = self.run_in_uv_env([str(self.cli_script), "-i", "newton"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i newton failed:\n{result.stdout}\n{result.stderr}" + + # Run isaaclab_newton test suite + test_dir = str(isaaclab_root / "source" / "isaaclab_newton" / "test") + result = self.run_in_uv_env( + ["python", "-m", "pytest", test_dir, "-sv", "--tb=short"], + cwd=isaaclab_root, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"isaaclab_newton tests failed (rc={result.returncode}):\n{output}" + + finally: + self.destroy_uv_env() diff --git a/source/isaaclab/test/install_ci/test_isaaclabx_i_physx.py b/source/isaaclab/test/install_ci/test_isaaclabx_i_physx.py new file mode 100644 index 000000000000..04bf2b346b23 --- /dev/null +++ b/source/isaaclab/test/install_ci/test_isaaclabx_i_physx.py @@ -0,0 +1,61 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Test installing isaaclab_physx and running its test suite.""" + +from __future__ import annotations + +import shutil + +import pytest +from utils import UV_Mixin, find_isaaclab_root + + +class Test_Install_Physx(UV_Mixin): + """Install ./isaaclab.sh -i physx and run the isaaclab_physx test suite.""" + + @classmethod + def setup_class(cls): + # check if uv is available + if not shutil.which("uv"): + pytest.skip("uv is not available") + + # check if isaacsim is importable + # or "_isaac_sim" link is present + try: + import isaacsim # noqa: F401 + except ImportError: + print("[DEBUG] Module isaacsim is not importable") + isaac_sim_link = find_isaaclab_root() / "_isaac_sim" + if not isaac_sim_link.exists(): + print(f'[DEBUG] Link "{isaac_sim_link}" does not exist') + pytest.skip("isaacsim is not importable and _isaac_sim link not found, skipping") + + @pytest.mark.uv + @pytest.mark.gpu + @pytest.mark.slow + @pytest.mark.native + @pytest.mark.timeout(3600) + def test_install_physx_and_run_tests(self, isaaclab_root): + """Install physx extension and run the isaaclab_physx test suite.""" + + try: + self.create_uv_env(isaaclab_root) + + # ./isaaclab.sh -i physx + result = self.run_in_uv_env([str(self.cli_script), "-i", "physx"], cwd=isaaclab_root) + assert result.returncode == 0, f"isaaclab -i physx failed:\n{result.stdout}\n{result.stderr}" + + # Run isaaclab_physx test suite + test_dir = str(isaaclab_root / "source" / "isaaclab_physx" / "test") + result = self.run_in_uv_env( + ["python", "-m", "pytest", test_dir, "-sv", "--tb=short"], + cwd=isaaclab_root, + ) + output = result.stdout + result.stderr + assert result.returncode == 0, f"isaaclab_physx tests failed (rc={result.returncode}):\n{output}" + + finally: + self.destroy_uv_env() diff --git a/source/isaaclab/test/install_ci/test_isaaclabx_uv_smoke.py b/source/isaaclab/test/install_ci/test_isaaclabx_uv_smoke.py index d0fb0fee6b5d..8bff8426e1fe 100644 --- a/source/isaaclab/test/install_ci/test_isaaclabx_uv_smoke.py +++ b/source/isaaclab/test/install_ci/test_isaaclabx_uv_smoke.py @@ -55,8 +55,8 @@ def test_isaaclab_install_assets(self, isaaclab_root): @pytest.mark.uv @pytest.mark.timeout(300) - def test_isaaclab_newton_installs_isaaclab_physx(self, isaaclab_root): - """Run ./isaaclab.x -i 'newton' and verify isaaclab_physx is importable.""" + def test_isaaclab_newton_installs_isaaclab_newton(self, isaaclab_root): + """Run ./isaaclab.x -i 'newton' and verify isaaclab_newton is importable.""" try: self.create_uv_env(isaaclab_root) @@ -65,9 +65,9 @@ def test_isaaclab_newton_installs_isaaclab_physx(self, isaaclab_root): result = self.run_in_uv_env([str(self.cli_script), "-i", "newton"], cwd=isaaclab_root) assert result.returncode == 0, f"isaaclab -i newton failed:\n{result.stdout}\n{result.stderr}" - # import isaaclab_physx - result = self.run_in_uv_env(["python", "-c", "import isaaclab_physx; print(isaaclab_physx.__version__)"]) - assert result.returncode == 0, f"import isaaclab_physx failed:\n{result.stdout}\n{result.stderr}" + # import isaaclab_newton + result = self.run_in_uv_env(["python", "-c", "import isaaclab_newton; print(isaaclab_newton.__version__)"]) + assert result.returncode == 0, f"import isaaclab_newton failed:\n{result.stdout}\n{result.stderr}" finally: self.destroy_uv_env() diff --git a/source/isaaclab/test/install_ci/utils.py b/source/isaaclab/test/install_ci/utils.py index 08f046e5bb8c..85055f26c0bc 100644 --- a/source/isaaclab/test/install_ci/utils.py +++ b/source/isaaclab/test/install_ci/utils.py @@ -15,6 +15,7 @@ import sys import time from pathlib import Path +from typing import Literal _DIM = "\033[2m" _MAGENTA = "\033[95m" @@ -24,6 +25,73 @@ # Set to True by conftest.py when pytest runs with -s / --capture=no. stream_output: bool = False +# ISAACLAB_INSTALL_CI_ENV can be set to override execution +# environment detection in install_ci tests +# (for testing the testing while testing). + +ExecutionEnvironment = Literal["docker", "native"] + + +def detect_execution_environment( + environ: dict[str, str] | None = None, + filesystem_root: Path | None = None, +) -> ExecutionEnvironment: + """Detect whether install_ci tests are running in Docker or natively.""" + env = environ if environ is not None else os.environ + root = filesystem_root if filesystem_root is not None else Path("/") + + override = env.get("ISAACLAB_INSTALL_CI_ENV") + if override: + cleaned = override.strip().lower() + if cleaned not in ("docker", "native"): + raise ValueError(f"ISAACLAB_INSTALL_CI_ENV must be 'docker' or 'native', got: {override!r}") + return cleaned # type: ignore[return-value] + + if (root / ".dockerenv").exists() or (root / "run" / ".containerenv").exists(): + return "docker" + + for cgroup_path in (root / "proc" / "1" / "cgroup", root / "proc" / "self" / "cgroup"): + try: + cgroup_text = cgroup_path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + if any( + hint in cgroup_text + for hint in ( + "docker", + "containerd", + "kubepods", + "libpod", + "podman", + ) + ): + return "docker" + + if env.get("container"): + return "docker" + + return "native" + + +def get_execution_environment_skip_reason( + marker_names: set[str], + execution_environment: ExecutionEnvironment, +) -> str | None: + """Return a skip reason when environment markers do not match the runtime.""" + has_docker = "docker" in marker_names + has_native = "native" in marker_names + + if has_docker and has_native: + raise ValueError("tests cannot be marked with both 'docker' and 'native'") + + if has_docker and execution_environment != "docker": + return f"requires Docker execution environment, detected {execution_environment}" + + if has_native and execution_environment != "native": + return f"requires native execution environment, detected {execution_environment}" + + return None + def find_isaaclab_root() -> Path: """Walk up from this file to find the repo root (contains isaaclab.sh).""" @@ -76,6 +144,7 @@ def run_cmd( stderr=subprocess.STDOUT, text=True, ) + assert proc.stdout is not None lines: list[str] = [] try: for line in proc.stdout: @@ -146,6 +215,9 @@ def create_uv_env(self, isaaclab_root: Path, env_name: str = "") -> None: assert result.returncode == 0, f"uv env creation failed:\n{result.stdout}\n{result.stderr}" assert self.env_path.exists(), f"Expected env directory {self.env_path} was not created" + # Prevent the venv from being tracked by git. + (self.env_path / ".gitignore").write_text("*\n") + self.python = (self.env_path / "Scripts" / "python.exe") if _IS_WINDOWS else (self.env_path / "bin" / "python") assert self.python.exists(), f"Python executable not found at {self.python}" diff --git a/source/isaaclab_newton/setup.py b/source/isaaclab_newton/setup.py index c83dd352710a..2e0b87f17543 100644 --- a/source/isaaclab_newton/setup.py +++ b/source/isaaclab_newton/setup.py @@ -33,11 +33,7 @@ def run(self): # Read the extension.toml file EXTENSION_TOML_DATA = toml.load(os.path.join(EXTENSION_PATH, "config", "extension.toml")) -INSTALL_REQUIRES = [ - # INTENTIONALLY disabled to avoid circular dependency with isaaclab_physx, which also depends on isaaclab_newton. - # This will be re-enabled once we move to UV and pyproject.toml-based packaging. - # f"isaaclab_physx @ file://{os.path.join(os.path.dirname(EXTENSION_PATH), 'isaaclab_physx')}", -] +INSTALL_REQUIRES = [] EXTRAS_REQUIRE = { "all": [ diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.pyi index d1612d1f3bbd..32c6f9c07335 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/__init__.pyi @@ -4,9 +4,7 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ - "NewtonSceneDataProvider", "PhysxSceneDataProvider", ] -from .newton_scene_data_provider import NewtonSceneDataProvider from .physx_scene_data_provider import PhysxSceneDataProvider diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py index 9403cd40aa46..ceb6089dc2ce 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py @@ -265,6 +265,7 @@ def _build_newton_model_from_usd(self) -> None: self._last_newton_model_build_source = ( "usd_fallback_forced" if self._force_usd_fallback_for_newton_model_build else "usd_fallback" ) + from newton import ModelBuilder builder = ModelBuilder(up_axis=self._up_axis) diff --git a/source/isaaclab_physx/setup.py b/source/isaaclab_physx/setup.py index bc37a24d1694..1e917e938c2b 100644 --- a/source/isaaclab_physx/setup.py +++ b/source/isaaclab_physx/setup.py @@ -16,11 +16,13 @@ EXTENSION_TOML_DATA = toml.load(os.path.join(EXTENSION_PATH, "config", "extension.toml")) # Minimum dependencies required prior to installation -INSTALL_REQUIRES = [ - # INTENTIONALLY disabled to avoid circular dependency with isaaclab_physx, which also depends on isaaclab_newton. - # This will be re-enabled once we move to UV and pyproject.toml-based packaging. - # f"isaaclab_newton @ file://{os.path.join(os.path.dirname(EXTENSION_PATH), 'isaaclab_newton')}", -] +INSTALL_REQUIRES = [] + +EXTRAS_REQUIRE = { + "newton": [ + "newton @ git+https://github.com/newton-physics/newton.git@2684d75bfa4bb8b058a93b81c458a74b7701c997", + ], +} # Installation operation setup( @@ -36,6 +38,7 @@ package_data={"": ["*.pyi"]}, python_requires=">=3.12", install_requires=INSTALL_REQUIRES, + extras_require=EXTRAS_REQUIRE, packages=[ "isaaclab_physx", "isaaclab_physx.assets", From 3d42b11d51335fdb3f13e2c159e9a9406980c661 Mon Sep 17 00:00:00 2001 From: myurasov-nv <168484206+myurasov-nv@users.noreply.github.com> Date: Tue, 21 Apr 2026 19:21:20 -0700 Subject: [PATCH 20/37] Fixes M1 3.0 GA Issues (#5343) # Description Fixes 3.0 GA M1 issues. Code changes in this PR fix: - NVBug 5985028 (also fixes 5984996) - Win11 isaaclab.bat install + Sim-binary detection on conda. - NVBug 5992915 - docs version selector now picks up pre-release tags like v3.0.0-beta. Triaged separately (no code changes needed in this PR): - NVBug 5974917 - hf-xet pip metadata error: fixed via PR#4992 plus Artifactory cache purge. - NVBug 5994306 - Docker image not found: already fixed in PR#5189 (ISAACSIM_VERSION=6.0.0-dev2). - NVBug 5974684 - pip [newton] warp-lang conflict: already aligned (warp-lang==1.12.0 in tools/wheel_builder/res/python_packages.toml). Needs next-rc-wheel re-verify. - NVBug 5983721 - pip pillow conflict: already aligned (pillow==12.1.1 in tools/wheel_builder/res/python_packages.toml). Needs next-rc-wheel re-verify. - NVBug 5983082 - duplicate of 5979273 (Isaac Sim sensors.rtx Windows DLL chain). - NVBug 5627823 - VDR docs feedback: item 1 already fixed on develop via -u/--uv; remaining items split into follow-ups. This should complete M1 issues. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks with ./isaaclab.sh --format - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's config/extension.toml file - [x] I have added my name to the CONTRIBUTORS.md or my name already exists there --- .github/workflows/docs.yaml | 7 ++--- .gitignore | 6 +++++ docs/conf.py | 6 +++-- isaaclab.bat | 7 +++++ isaaclab.sh | 8 ++++++ source/isaaclab/isaaclab/cli/utils.py | 16 +++++------- .../isaaclab_tasks/utils/sim_launcher.py | 26 +++++++++++++++++++ 7 files changed, 61 insertions(+), 15 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 85c49fadbbf4..1be69475745c 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -94,10 +94,11 @@ jobs: working-directory: ./docs env: # "deploy" branches build the full set of versions so every page - # has a complete version dropdown: main, develop, tags >= v2.0.0. - # v1.x tags and release/ branches are excluded to keep it lean and mean. + # has a complete version dropdown: main, develop, tags >= v2.0.0 + # (including pre-release suffixes like -beta or -rc1). v1.x tags and + # release/ branches are excluded. SMV_BRANCH_WHITELIST: '^(main|develop)$' - SMV_TAG_WHITELIST: '^v[2-9]\d*\.\d+\.\d+$' + SMV_TAG_WHITELIST: '^v[2-9]\d*\.\d+\.\d+(-[A-Za-z0-9.]+)?$' run: | git fetch --prune --unshallow --tags git checkout --detach HEAD diff --git a/.gitignore b/.gitignore index 5d4c8f954fc0..4b345b0a7d24 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,9 @@ _build # Ruff cache **/.ruff_cache/ + +# Dev-time files, generated stuff +**/__* + +# Isaac Lab CI environments in native mode +**/_isaaclab_install_ci_* diff --git a/docs/conf.py b/docs/conf.py index fcd2bcb9eca0..2fc604aad10d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -304,8 +304,10 @@ smv_remote_whitelist = r"^.*$" # Whitelist pattern for branches (set to None to ignore all branches) smv_branch_whitelist = os.getenv("SMV_BRANCH_WHITELIST", r"^(main|develop|release/.*)$") -# Whitelist pattern for tags (set to None to ignore all tags) -smv_tag_whitelist = os.getenv("SMV_TAG_WHITELIST", r"^v[1-9]\d*\.\d+\.\d+$") +# Whitelist pattern for tags (set to None to ignore all tags). +# Matches vMAJOR.MINOR.PATCH with an optional pre-release suffix like -beta or -rc1, +# so tags like v3.0.0-beta show up in the version selector. +smv_tag_whitelist = os.getenv("SMV_TAG_WHITELIST", r"^v[1-9]\d*\.\d+\.\d+(-[A-Za-z0-9.]+)?$") html_sidebars = { "**": ["navbar-logo.html", "versioning.html", "icon-links.html", "search-field.html", "sbt-sidebar-nav.html"] } diff --git a/isaaclab.bat b/isaaclab.bat index 077e9a4b1abd..1d8fb8275467 100644 --- a/isaaclab.bat +++ b/isaaclab.bat @@ -26,6 +26,13 @@ if defined VIRTUAL_ENV ( rem Add source/isaaclab to PYTHONPATH so we can import isaaclab.cli. set "PYTHONPATH=%ISAACLAB_PATH%\source\isaaclab;%PYTHONPATH%" +rem If a local Isaac Sim binary is present, source its env setup so that +rem PYTHONPATH/PATH/EXP_PATH are correct without depending on a conda +rem activate.d hook (those don't fire under e.g. `conda run` on Windows). +if exist "%ISAACLAB_PATH%\_isaac_sim\setup_conda_env.bat" ( + call "%ISAACLAB_PATH%\_isaac_sim\setup_conda_env.bat" >NUL +) + rem Execute CLI. "%python_exe%" -c "from isaaclab.cli import cli; cli()" %* diff --git a/isaaclab.sh b/isaaclab.sh index 8d535a10b307..d4042353e88f 100755 --- a/isaaclab.sh +++ b/isaaclab.sh @@ -28,5 +28,13 @@ fi # Add source/isaaclab to PYTHONPATH so we can import isaaclab.cli. export PYTHONPATH="$ISAACLAB_PATH/source/isaaclab:$PYTHONPATH" +# If a local Isaac Sim binary is present, source its env setup so that +# PYTHONPATH/PATH/EXP_PATH are correct without depending on a conda +# activate.d hook (those don't fire reliably under e.g. `conda run`). +if [ -f "$ISAACLAB_PATH/_isaac_sim/setup_conda_env.sh" ]; then + # shellcheck disable=SC1091 + . "$ISAACLAB_PATH/_isaac_sim/setup_conda_env.sh" >/dev/null 2>&1 || true +fi + # Execute CLI. exec "$python_exe" -c "from isaaclab.cli import cli; cli()" "$@" diff --git a/source/isaaclab/isaaclab/cli/utils.py b/source/isaaclab/isaaclab/cli/utils.py index a1870b9c2225..5a3b60532870 100644 --- a/source/isaaclab/isaaclab/cli/utils.py +++ b/source/isaaclab/isaaclab/cli/utils.py @@ -146,19 +146,16 @@ def _print_debug_env(prefix: str, env: dict[str, str] | None) -> None: _CMD_METACHARACTERS = frozenset("<>|&^") -def _escape_for_cmd_exe(cmd: list[str] | tuple[str, ...]) -> str | list[str]: +def _escape_for_cmd_exe(cmd: list[str] | tuple[str, ...]) -> list[str]: + """Wrap .bat/.cmd calls in cmd.exe /c so args with < > | & ^ stay literal + (otherwise Windows treats e.g. setuptools<82.0.0 as a redirection). """ - Quote ``cmd.exe`` metacharacters when invoking ``.bat``/``.cmd`` files. - - Returns a command string (not list) so ``subprocess.run`` bypasses - ``list2cmdline`` which doesn't escape cmd.exe metacharacters (<, >, |, &, ^). - """ - # Only .bat/.cmd files are executed via cmd.exe. + # only .bat/.cmd needs wrapping exe = str(cmd[0]).lower() if not (exe.endswith(".bat") or exe.endswith(".cmd")): return list(cmd) - # Wrap args that contain metacharacters or whitespace in double quotes. + # quote anything with metacharacters or spaces parts: list[str] = [] for arg in cmd: s = str(arg) @@ -167,8 +164,7 @@ def _escape_for_cmd_exe(cmd: list[str] | tuple[str, ...]) -> str | list[str]: else: parts.append(s) - # Return a string so subprocess skips list2cmdline. - return " ".join(parts) + return ["cmd.exe", "/c", " ".join(parts)] def run_command( diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index 964027858dae..f1d5f2208926 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -190,12 +190,38 @@ def launch_simulation( import importlib.util if importlib.util.find_spec("omni.kit") is None: + # Print a more obvious hint when a local _isaac_sim symlink + # exists but its env wasn't sourced (typical on Win11 + conda + # when activate.d hooks didn't fire, e.g. under `conda run`). + import os + import sys + + isaaclab_path = os.environ.get("ISAACLAB_PATH") + local_sim = os.path.join(isaaclab_path, "_isaac_sim") if isaaclab_path else None + extra_hint = "" + if local_sim and os.path.isdir(local_sim): + if sys.platform == "win32": + extra_hint = ( + f" Found a local Isaac Sim at {local_sim} but its environment is not active.\n" + f" Either run via `isaaclab.bat ...` (which now sources setup_conda_env.bat\n" + f" automatically), or in your current shell run:\n" + f' call "{local_sim}\\setup_conda_env.bat"\n' + ) + else: + extra_hint = ( + f" Found a local Isaac Sim at {local_sim} but its environment is not active.\n" + f" Either run via `./isaaclab.sh ...` (which now sources setup_conda_env.sh\n" + f" automatically), or in your current shell run:\n" + f' source "{local_sim}/setup_conda_env.sh"\n' + ) + logger.error( "\n[ERROR] Isaac Sim is not installed or not found on PYTHONPATH.\n" "\n" " This environment requires Isaac Sim and Omniverse Kit.\n" " PhysX backend and Kit visualizer currently requires Isaac Sim.\n" "\n" + f"{extra_hint}" " To fix this, ensure Isaac Sim is installed and available in the current environment.\n" "\n" " See https://isaac-sim.github.io/IsaacLab/main/source/setup/installation for details.\n" From 1c237bdb60dde4e2933019ae7bf6dd7ad77cc93d Mon Sep 17 00:00:00 2001 From: Pascal Roth <57946385+pascal-roth@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:26:03 +0200 Subject: [PATCH 21/37] Transitions raycaster to warp backend (#4967) # Description Transitions the raycaster to warp ## Type of change - Bug fix (non-breaking change which fixes an issue) - New feature (non-breaking change which adds functionality) ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Antoine Richard --- docs/index.rst | 2 +- .../migration/migrating_to_isaaclab_3-0.rst | 131 ++++ docs/source/testing/index.rst | 2 +- scripts/demos/sensors/raycaster_sensor.py | 4 +- .../04_sensors/add_sensors_on_robot.py | 5 +- .../tutorials/04_sensors/run_ray_caster.py | 2 +- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 55 ++ .../isaaclab/envs/mdp/observations.py | 2 +- source/isaaclab/isaaclab/envs/mdp/rewards.py | 2 +- .../isaaclab/sensors/ray_caster/kernels.py | 261 ++++++++ .../ray_caster/multi_mesh_ray_caster.py | 196 +++--- .../multi_mesh_ray_caster_camera.py | 268 +++++--- .../multi_mesh_ray_caster_camera_data.py | 10 +- .../isaaclab/sensors/ray_caster/ray_caster.py | 312 ++++++---- .../sensors/ray_caster/ray_caster_camera.py | 285 +++++++-- .../ray_caster/ray_caster_camera_cfg.py | 4 +- .../sensors/ray_caster/ray_caster_cfg.py | 5 +- .../sensors/ray_caster/ray_caster_data.py | 81 ++- .../isaaclab/isaaclab/utils/warp/kernels.py | 120 +++- source/isaaclab/isaaclab/utils/warp/ops.py | 12 + .../test_multi_mesh_ray_caster_camera.py | 32 + .../isaaclab/test/sensors/test_ray_caster.py | 206 ++++++- .../test/sensors/test_ray_caster_camera.py | 139 +++++ .../sensors/test_ray_caster_integration.py | 439 +++++++++++++ .../test/sensors/test_ray_caster_kernels.py | 577 ++++++++++++++++++ .../test/sensors/test_ray_caster_sensor.py | 272 +++++++++ .../sensors/test_update_ray_caster_kernel.py | 510 ++++++++++++++++ .../direct/anymal_c/anymal_c_env.py | 4 +- 29 files changed, 3515 insertions(+), 425 deletions(-) create mode 100644 source/isaaclab/isaaclab/sensors/ray_caster/kernels.py create mode 100644 source/isaaclab/test/sensors/test_ray_caster_integration.py create mode 100644 source/isaaclab/test/sensors/test_ray_caster_kernels.py create mode 100644 source/isaaclab/test/sensors/test_ray_caster_sensor.py create mode 100644 source/isaaclab/test/sensors/test_update_ray_caster_kernel.py diff --git a/docs/index.rst b/docs/index.rst index 9a8d91664f7b..6c8e6ba9ac41 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,7 +52,7 @@ For more information about the framework, please refer to the `technical report License -======= +======== The Isaac Lab framework is open-sourced under the BSD-3-Clause license, with certain parts under Apache-2.0 license. Please refer to :ref:`license` for more details. diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index 12e0501c9028..6855a4fda28a 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -955,6 +955,14 @@ Common patterns that need updating: - ``isaaclab_physx`` * - :class:`~isaaclab_physx.sensors.FrameTransformer` - ``isaaclab_physx`` + * - :class:`~isaaclab.sensors.RayCaster` + - ``isaaclab`` + * - :class:`~isaaclab.sensors.RayCasterCamera` + - ``isaaclab`` + * - :class:`~isaaclab.sensors.MultiMeshRayCaster` + - ``isaaclab`` + * - :class:`~isaaclab.sensors.MultiMeshRayCasterCamera` + - ``isaaclab`` .. note:: @@ -974,6 +982,129 @@ Common patterns that need updating: already passed to warp-native functions) should not be wrapped. +Ray Caster Warp Backend +~~~~~~~~~~~~~~~~~~~~~~~ + +The :class:`~isaaclab.sensors.RayCaster`, :class:`~isaaclab.sensors.RayCasterCamera`, +:class:`~isaaclab.sensors.MultiMeshRayCaster`, and +:class:`~isaaclab.sensors.MultiMeshRayCasterCamera` sensors have been transitioned from a +PyTorch/USD-based backend to a native Warp kernel pipeline. This improves performance by +eliminating per-step tensor allocations and torch-to-warp conversions, but introduces several +breaking changes. + + +RayCasterData Return Types +-------------------------- + +The :attr:`~isaaclab.sensors.RayCasterData.pos_w`, +:attr:`~isaaclab.sensors.RayCasterData.quat_w`, and +:attr:`~isaaclab.sensors.RayCasterData.ray_hits_w` properties now return ``wp.array`` instead of +``torch.Tensor``. This follows the same pattern as the general warp backend migration described +above. + +.. code-block:: python + + import warp as wp + + # Before (Isaac Lab 2.x) + ray_hits = ray_caster.data.ray_hits_w # torch.Tensor + sensor_pos = ray_caster.data.pos_w # torch.Tensor + + # After (Isaac Lab 3.x) + ray_hits = ray_caster.data.ray_hits_w # wp.array + sensor_pos = ray_caster.data.pos_w # wp.array + + # To use with torch operations, wrap with wp.to_torch() + ray_hits_torch = wp.to_torch(ray_caster.data.ray_hits_w) + sensor_pos_torch = wp.to_torch(ray_caster.data.pos_w) + + +Ray Alignment Configuration +---------------------------- + +The ``attach_yaw_only`` boolean parameter on :class:`~isaaclab.sensors.RayCasterCfg` has been +deprecated in favor of the new ``ray_alignment`` parameter, which accepts one of three string +values: + +.. list-table:: + :header-rows: 1 + :widths: 30 30 40 + + * - Old (2.x) + - New (3.0) + - Behavior + * - ``attach_yaw_only=False`` + - ``ray_alignment="base"`` + - Rays follow the full sensor orientation. + * - ``attach_yaw_only=True`` + - ``ray_alignment="yaw"`` + - Rays follow only the yaw component of the sensor orientation. + * - *(not available)* + - ``ray_alignment="world"`` + - Rays are always cast in the world frame (no rotation applied). + +.. code-block:: python + + # Before (Isaac Lab 2.x) + cfg = RayCasterCfg(attach_yaw_only=True, ...) + + # After (Isaac Lab 3.x) + cfg = RayCasterCfg(ray_alignment="yaw", ...) + + +Raycasting Kernel Signature Change +----------------------------------- + +The :func:`~isaaclab.utils.warp.kernels.raycast_dynamic_meshes_kernel` Warp kernel now requires +an ``env_mask`` parameter as its first argument. This is a ``wp.array(dtype=wp.bool)`` that +controls which environments are updated. The public Python wrapper +:func:`~isaaclab.utils.warp.ops.raycast_dynamic_meshes` has been updated to inject an all-True +mask automatically, so code using the wrapper is unaffected. + +If you call the kernel directly, update your launch call: + +.. code-block:: python + + import warp as wp + + # Before (Isaac Lab 2.x) + wp.launch( + raycast_dynamic_meshes_kernel, + dim=(num_meshes, num_envs, num_rays), + inputs=[ray_starts, ray_directions, mesh_ids, ...], + ) + + # After (Isaac Lab 3.x) -- env_mask is now the first input + env_mask = wp.ones(num_envs, dtype=wp.bool, device=device) + wp.launch( + raycast_dynamic_meshes_kernel, + dim=(num_meshes, num_envs, num_rays), + inputs=[env_mask, ray_starts, ray_directions, mesh_ids, ...], + ) + + +RayCaster.meshes Cache Key +-------------------------- + +The :attr:`~isaaclab.sensors.RayCaster.meshes` class variable, which caches warp meshes across +all :class:`~isaaclab.sensors.RayCaster` instances, is now keyed by ``(prim_path, device)`` tuples +instead of by ``prim_path`` alone. This prevents a mesh that was built on one device (e.g. CPU) +from being reused by a sensor running on a different device (e.g. CUDA), which caused illegal +memory accesses on systems without unified memory. + +Code that reads or writes this cache directly must update both the type annotation and the key: + +.. code-block:: python + + # Before (Isaac Lab 2.x) + meshes: ClassVar[dict[str, wp.Mesh]] = {} + wp_mesh = RayCaster.meshes[prim_path] + + # After (Isaac Lab 3.x) + meshes: ClassVar[dict[tuple[str, str], wp.Mesh]] = {} + wp_mesh = RayCaster.meshes[(prim_path, device)] + + Write Method Index/Mask Split ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/testing/index.rst b/docs/source/testing/index.rst index ae8494a3ec89..4d875d982797 100644 --- a/docs/source/testing/index.rst +++ b/docs/source/testing/index.rst @@ -1,7 +1,7 @@ .. _testing: Testing -======= +======== This section covers testing utilities and patterns for Isaac Lab development. diff --git a/scripts/demos/sensors/raycaster_sensor.py b/scripts/demos/sensors/raycaster_sensor.py index 43c6eb6911e0..4f758274b61c 100644 --- a/scripts/demos/sensors/raycaster_sensor.py +++ b/scripts/demos/sensors/raycaster_sensor.py @@ -127,13 +127,13 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): # print information from the sensors print("-------------------------------") print(scene["ray_caster"]) - print("Ray cast hit results: ", scene["ray_caster"].data.ray_hits_w) + print("Ray cast hit results: ", wp.to_torch(scene["ray_caster"].data.ray_hits_w)) if not triggered: if countdown > 0: countdown -= 1 continue - data = scene["ray_caster"].data.ray_hits_w.cpu().numpy() + data = wp.to_torch(scene["ray_caster"].data.ray_hits_w).cpu().numpy() np.save("cast_data.npy", data) triggered = True else: diff --git a/scripts/tutorials/04_sensors/add_sensors_on_robot.py b/scripts/tutorials/04_sensors/add_sensors_on_robot.py index 31f9a2bcefcb..f5e3a19c0bec 100644 --- a/scripts/tutorials/04_sensors/add_sensors_on_robot.py +++ b/scripts/tutorials/04_sensors/add_sensors_on_robot.py @@ -150,7 +150,10 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene): print("Received shape of depth image: ", scene["camera"].data.output["distance_to_image_plane"].shape) print("-------------------------------") print(scene["height_scanner"]) - print("Received max height value: ", torch.max(scene["height_scanner"].data.ray_hits_w[..., -1]).item()) + print( + "Received max height value: ", + torch.max(wp.to_torch(scene["height_scanner"].data.ray_hits_w)[..., -1]).item(), + ) print("-------------------------------") print(scene["contact_forces"]) print("Received max contact force of: ", torch.max(scene["contact_forces"].data.net_forces_w).item()) diff --git a/scripts/tutorials/04_sensors/run_ray_caster.py b/scripts/tutorials/04_sensors/run_ray_caster.py index 3e46ef1a08fd..ff66ff9a0fd2 100644 --- a/scripts/tutorials/04_sensors/run_ray_caster.py +++ b/scripts/tutorials/04_sensors/run_ray_caster.py @@ -120,7 +120,7 @@ def run_simulator(sim: sim_utils.SimulationContext, scene_entities: dict): # Update the ray-caster with Timer( f"Ray-caster update with {4} x {ray_caster.num_rays} rays with max height of" - f" {torch.max(ray_caster.data.pos_w).item():.2f}" + f" {torch.max(wp.to_torch(ray_caster.data.pos_w)).item():.2f}" ): ray_caster.update(dt=sim.get_physics_dt(), force_recompute=True) # Update counter diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 35664f87df0a..55b947608fd8 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.8" +version = "4.6.9" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 4f945e95a27a..65768daf0b3c 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,61 @@ Changelog --------- +4.6.9 (2026-04-22) +~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Converted all four ray caster sensor classes (:class:`~isaaclab.sensors.RayCaster`, + :class:`~isaaclab.sensors.RayCasterCamera`, :class:`~isaaclab.sensors.MultiMeshRayCaster`, + :class:`~isaaclab.sensors.MultiMeshRayCasterCamera`) to launch Warp kernels directly via + ``wp.launch`` instead of going through Python-level torch wrappers. A new + :mod:`~isaaclab.sensors.ray_caster.kernels` module contains all sensor-specific kernels. + All intermediate ray buffers are now Warp-owned with zero-copy torch views, eliminating + per-step allocations. The existing :func:`~isaaclab.utils.warp.kernels.raycast_dynamic_meshes_kernel` + gained an ``env_mask`` parameter to support partial environment updates natively. A new + :func:`~isaaclab.utils.warp.kernels.raycast_mesh_masked_kernel` was added to + :mod:`~isaaclab.utils.warp.kernels` as the general-purpose masked single-mesh variant, + with ``return_distance`` and ``return_normal`` flags matching the design of + :func:`~isaaclab.utils.warp.kernels.raycast_mesh_kernel`. + + **Breaking change** — :attr:`~isaaclab.sensors.RayCasterData.pos_w`, + :attr:`~isaaclab.sensors.RayCasterData.quat_w`, and + :attr:`~isaaclab.sensors.RayCasterData.ray_hits_w` now return :class:`wp.array` + instead of :class:`torch.Tensor`. Call-sites that previously accessed these as tensors + must wrap the result with :func:`wp.to_torch`: + + .. code-block:: python + + # Before + hits = sensor.data.ray_hits_w # torch.Tensor (old) + # After + hits = wp.to_torch(sensor.data.ray_hits_w) # torch.Tensor (zero-copy view) + +* Changed the :attr:`~isaaclab.sensors.RayCaster.meshes` class variable cache key from + ``prim_path`` to a ``(prim_path, device)`` tuple so that meshes built on one device + (e.g. CPU) are not reused by a sensor running on another device (e.g. CUDA). + + **Breaking change** — callers that read or write :attr:`~isaaclab.sensors.RayCaster.meshes` + directly must update the key: + + .. code-block:: python + + # Before + wp_mesh = RayCaster.meshes[prim_path] + # After + wp_mesh = RayCaster.meshes[(prim_path, device)] + +Fixed +^^^^^ + +* Fixed frame composition in :meth:`~isaaclab.sensors.MultiMeshRayCaster._update_mesh_transforms` + which used simple subtraction instead of proper frame decomposition when applying mesh offsets. + With non-identity orientation offsets, tracked mesh positions were incorrect, causing raycasts to + miss or hit wrong surfaces. The method now uses :func:`~isaaclab.utils.math.combine_frame_transforms`. + + 4.6.8 (2026-04-21) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/envs/mdp/observations.py b/source/isaaclab/isaaclab/envs/mdp/observations.py index a207749550a9..d65bd264f7ed 100644 --- a/source/isaaclab/isaaclab/envs/mdp/observations.py +++ b/source/isaaclab/isaaclab/envs/mdp/observations.py @@ -304,7 +304,7 @@ def height_scan(env: ManagerBasedEnv, sensor_cfg: SceneEntityCfg, offset: float # extract the used quantities (to enable type-hinting) sensor: RayCaster = env.scene.sensors[sensor_cfg.name] # height scan: height = sensor_height - hit_point_z - offset - return sensor.data.pos_w[:, 2].unsqueeze(1) - sensor.data.ray_hits_w[..., 2] - offset + return wp.to_torch(sensor.data.pos_w)[:, 2].unsqueeze(1) - wp.to_torch(sensor.data.ray_hits_w)[..., 2] - offset def body_incoming_wrench(env: ManagerBasedEnv, asset_cfg: SceneEntityCfg) -> torch.Tensor: diff --git a/source/isaaclab/isaaclab/envs/mdp/rewards.py b/source/isaaclab/isaaclab/envs/mdp/rewards.py index 5a53583f5e4b..74bea7ee7861 100644 --- a/source/isaaclab/isaaclab/envs/mdp/rewards.py +++ b/source/isaaclab/isaaclab/envs/mdp/rewards.py @@ -116,7 +116,7 @@ def base_height_l2( if sensor_cfg is not None: sensor: RayCaster = env.scene[sensor_cfg.name] # Adjust the target height using the sensor data - adjusted_target_height = target_height + torch.mean(sensor.data.ray_hits_w[..., 2], dim=1) + adjusted_target_height = target_height + torch.mean(wp.to_torch(sensor.data.ray_hits_w)[..., 2], dim=1) else: # Use the provided target height directly for flat terrain adjusted_target_height = target_height diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/kernels.py b/source/isaaclab/isaaclab/sensors/ray_caster/kernels.py new file mode 100644 index 000000000000..98c54ea7141d --- /dev/null +++ b/source/isaaclab/isaaclab/sensors/ray_caster/kernels.py @@ -0,0 +1,261 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Warp kernels for the ray caster sensor.""" + +import warp as wp + +ALIGNMENT_WORLD = wp.constant(0) +ALIGNMENT_YAW = wp.constant(1) +ALIGNMENT_BASE = wp.constant(2) + +# Upper-bound ray-cast distance [m] used by camera classes. The actual depth-clipping is applied +# as a post-process step per data type, so the kernel is always given a large budget. +CAMERA_RAYCAST_MAX_DIST: float = 1e6 + + +@wp.func +def quat_yaw_only( + # input + q: wp.quatf, +) -> wp.quatf: + """Extract the yaw-only quaternion from a general quaternion. + + Equivalent to :func:`isaaclab.utils.math.yaw_quat`: extracts the yaw angle via + ``atan2(2*(qw*qz + qx*qy), 1 - 2*(qy^2 + qz^2))`` and returns a pure-yaw quaternion + ``(0, 0, sin(yaw/2), cos(yaw/2))``. This is correct for all orientations, including + those with non-zero roll and pitch. + """ + qx = q[0] + qy = q[1] + qz = q[2] + qw = q[3] + yaw = wp.atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz)) + half_yaw = yaw * 0.5 + return wp.quatf(0.0, 0.0, wp.sin(half_yaw), wp.cos(half_yaw)) + + +@wp.kernel(enable_backward=False) +def update_ray_caster_kernel( + # input + transforms: wp.array(dtype=wp.transformf), + env_mask: wp.array(dtype=wp.bool), + offset_pos: wp.array(dtype=wp.vec3f), + offset_quat: wp.array(dtype=wp.quatf), + drift: wp.array(dtype=wp.vec3f), + ray_cast_drift: wp.array(dtype=wp.vec3f), + ray_starts_local: wp.array2d(dtype=wp.vec3f), + ray_directions_local: wp.array2d(dtype=wp.vec3f), + alignment_mode: int, + # output + pos_w: wp.array(dtype=wp.vec3f), + quat_w: wp.array(dtype=wp.quatf), + ray_starts_w: wp.array2d(dtype=wp.vec3f), + ray_directions_w: wp.array2d(dtype=wp.vec3f), +): + """Compute sensor world poses and transform rays into world frame. + + Combines the PhysX view transform with the sensor offset, applies drift, + and transforms local ray starts/directions according to the alignment mode. + + Launch with dim=(num_envs, num_rays). + + Args: + transforms: World transforms from PhysX view. Shape is (num_envs,). + env_mask: Boolean mask for which environments to update. Shape is (num_envs,). + offset_pos: Per-env position offset [m] from view to sensor. Shape is (num_envs,). + offset_quat: Per-env quaternion offset from view to sensor. Shape is (num_envs,). + drift: Per-env position drift [m]. Shape is (num_envs,). + ray_cast_drift: Per-env ray cast drift [m]. Shape is (num_envs,). + After rotation by the alignment quaternion, only the x and y components + are applied to the ray start position; the z component of the sensor + position is preserved. + ray_starts_local: Per-env local ray start positions [m]. Shape is (num_envs, num_rays). + ray_directions_local: Per-env local ray directions (unit vectors). Shape is (num_envs, num_rays). + alignment_mode: 0=world, 1=yaw, 2=base. + pos_w: Output sensor position in world frame [m]. Shape is (num_envs,). + quat_w: Output sensor orientation in world frame. Shape is (num_envs,). + ray_starts_w: Output world-frame ray starts [m]. Shape is (num_envs, num_rays). + ray_directions_w: Output world-frame ray directions (unit vectors). Shape is (num_envs, num_rays). + """ + env_id, ray_id = wp.tid() + if not env_mask[env_id]: + return + + t = transforms[env_id] + view_pos = wp.transform_get_translation(t) + view_quat = wp.transform_get_rotation(t) + + # combine_frame_transforms: q02 = q01 * q12, t02 = t01 + quat_rotate(q01, t12) + combined_quat = view_quat * offset_quat[env_id] + combined_pos = view_pos + wp.quat_rotate(view_quat, offset_pos[env_id]) + + combined_pos = combined_pos + drift[env_id] + + if ray_id == 0: + pos_w[env_id] = combined_pos + quat_w[env_id] = combined_quat + + local_start = ray_starts_local[env_id, ray_id] + local_dir = ray_directions_local[env_id, ray_id] + rcd = ray_cast_drift[env_id] + + if alignment_mode == ALIGNMENT_WORLD: + pos_drifted = wp.vec3f(combined_pos[0] + rcd[0], combined_pos[1] + rcd[1], combined_pos[2]) + ray_starts_w[env_id, ray_id] = local_start + pos_drifted + ray_directions_w[env_id, ray_id] = local_dir + elif alignment_mode == ALIGNMENT_YAW: + yaw_q = quat_yaw_only(combined_quat) + rot_drift = wp.quat_rotate(yaw_q, rcd) + pos_drifted = wp.vec3f(combined_pos[0] + rot_drift[0], combined_pos[1] + rot_drift[1], combined_pos[2]) + ray_starts_w[env_id, ray_id] = wp.quat_rotate(yaw_q, local_start) + pos_drifted + # Ray DIRECTIONS are intentionally NOT rotated in yaw mode: the sensor's ray pattern + # (e.g. straight-down (0,0,-1) for a height scanner) stays fixed in world frame. + # Only ray STARTS are rotated by the yaw-only quaternion so the scan footprint + # follows the body heading without tilting when the body pitches or rolls. + ray_directions_w[env_id, ray_id] = local_dir + else: + rot_drift = wp.quat_rotate(combined_quat, rcd) + pos_drifted = wp.vec3f(combined_pos[0] + rot_drift[0], combined_pos[1] + rot_drift[1], combined_pos[2]) + ray_starts_w[env_id, ray_id] = wp.quat_rotate(combined_quat, local_start) + pos_drifted + ray_directions_w[env_id, ray_id] = wp.quat_rotate(combined_quat, local_dir) + + +@wp.kernel(enable_backward=False) +def fill_vec3_inf_kernel( + # input + env_mask: wp.array(dtype=wp.bool), + inf_val: wp.float32, + # output + data: wp.array2d(dtype=wp.vec3f), +): + """Fill a 2D vec3f array with a given value for masked environments. + + Launch with dim=(num_envs, num_rays). + + Args: + env_mask: Boolean mask for which environments to update. Shape is (num_envs,). + inf_val: Value to fill with (typically inf). + data: Array to fill. Shape is (num_envs, num_rays). + """ + env, ray = wp.tid() + if not env_mask[env]: + return + data[env, ray] = wp.vec3f(inf_val, inf_val, inf_val) + + +@wp.kernel(enable_backward=False) +def apply_z_drift_kernel( + # input + env_mask: wp.array(dtype=wp.bool), + ray_cast_drift: wp.array(dtype=wp.vec3f), + # output + ray_hits: wp.array2d(dtype=wp.vec3f), +): + """Apply vertical (z) drift to ray hit positions for masked environments. + + Launch with dim=(num_envs, num_rays). + + Args: + env_mask: Boolean mask for which environments to update. Shape is (num_envs,). + ray_cast_drift: Per-env drift vector [m]; only z-component is used. Shape is (num_envs,). + ray_hits: Ray hit positions to modify in-place. Shape is (num_envs, num_rays). + """ + env, ray = wp.tid() + if not env_mask[env]: + return + hit = ray_hits[env, ray] + ray_hits[env, ray] = wp.vec3f(hit[0], hit[1], hit[2] + ray_cast_drift[env][2]) + + +@wp.kernel(enable_backward=False) +def fill_float2d_masked_kernel( + # input + env_mask: wp.array(dtype=wp.bool), + val: wp.float32, + # output + data: wp.array2d(dtype=wp.float32), +): + """Fill a 2D float32 array with a given value for masked environments. + + Launch with dim=(num_envs, num_rays). + + Args: + env_mask: Boolean mask for which environments to update. Shape is (num_envs,). + val: Value to fill with. + data: Array to fill. Shape is (num_envs, num_rays). + """ + env, ray = wp.tid() + if not env_mask[env]: + return + data[env, ray] = val + + +@wp.kernel(enable_backward=False) +def compute_distance_to_image_plane_masked_kernel( + # input + env_mask: wp.array(dtype=wp.bool), + quat_w: wp.array(dtype=wp.quatf), + ray_distance: wp.array2d(dtype=wp.float32), + ray_directions_w: wp.array2d(dtype=wp.vec3f), + # output + distance_to_image_plane: wp.array2d(dtype=wp.float32), +): + """Compute distance-to-image-plane from ray depth and direction for masked environments. + + The distance to the image plane is the signed projection of the hit displacement + (``ray_distance * ray_direction_w``) onto the camera forward axis (+X in world convention). + This equals the x-component of the hit vector in the camera frame. + + Launch with dim=(num_envs, num_rays). + + Args: + env_mask: Boolean mask for which environments to update. Shape is (num_envs,). + quat_w: Camera orientation in world frame (x, y, z, w). Shape is (num_envs,). + ray_distance: Per-ray hit distances [m]. Shape is (num_envs, num_rays). + Contains inf for missed rays. + ray_directions_w: World-frame unit ray directions. Shape is (num_envs, num_rays). + distance_to_image_plane: Output distance-to-image-plane [m]. Shape is (num_envs, num_rays). + """ + env, ray = wp.tid() + if not env_mask[env]: + return + + depth = ray_distance[env, ray] + dir_w = ray_directions_w[env, ray] + # displacement vector in world frame + disp_w = wp.vec3f(depth * dir_w[0], depth * dir_w[1], depth * dir_w[2]) + # rotate into camera frame (quat_rotate_inv applies q^-1 * v * q) + disp_cam = wp.quat_rotate_inv(quat_w[env], disp_w) + # x-component is the forward (depth) axis of the camera in world convention + distance_to_image_plane[env, ray] = disp_cam[0] + + +@wp.kernel(enable_backward=False) +def apply_depth_clipping_masked_kernel( + # input + env_mask: wp.array(dtype=wp.bool), + max_dist: wp.float32, + fill_val: wp.float32, + # output + depth: wp.array2d(dtype=wp.float32), +): + """Clip depth values in-place, replacing values above max_dist or NaN with fill_val. + + Launch with dim=(num_envs, num_rays). + + Args: + env_mask: Boolean mask for which environments to update. Shape is (num_envs,). + max_dist: Maximum depth threshold [m]. + fill_val: Replacement value [m] written for depths exceeding max_dist or NaN. + Pass ``max_dist`` for "max" clipping or ``0.0`` for "zero" clipping. + depth: Depth buffer to clip in-place. Shape is (num_envs, num_rays). + """ + env, ray = wp.tid() + if not env_mask[env]: + return + val = depth[env, ray] + if val > max_dist or wp.isnan(val): + depth[env, ray] = fill_val diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster.py b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster.py index 06ce2183e2ff..002456b6e101 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster.py @@ -18,10 +18,12 @@ import isaaclab.sim as sim_utils from isaaclab.sim.views import XformPrimView -from isaaclab.utils.math import matrix_from_quat, quat_mul +from isaaclab.utils.math import combine_frame_transforms, matrix_from_quat from isaaclab.utils.mesh import PRIMITIVE_MESH_TYPES, create_trimesh_from_geom_mesh, create_trimesh_from_geom_shape -from isaaclab.utils.warp import convert_to_warp_mesh, raycast_dynamic_meshes +from isaaclab.utils.warp import convert_to_warp_mesh +from isaaclab.utils.warp import kernels as warp_kernels +from .kernels import fill_float2d_masked_kernel, fill_vec3_inf_kernel from .multi_mesh_ray_caster_data import MultiMeshRayCasterData from .ray_cast_utils import obtain_world_pose_from_view from .ray_caster import RayCaster @@ -29,7 +31,6 @@ if TYPE_CHECKING: from .multi_mesh_ray_caster_cfg import MultiMeshRayCasterCfg -# import logger logger = logging.getLogger(__name__) @@ -41,8 +42,8 @@ class MultiMeshRayCaster(RayCaster): a set of meshes with a given ray pattern. The meshes are parsed from the list of primitive paths provided in the configuration. These are then - converted to warp meshes and stored in the :attr:`meshes` list. The ray-caster then ray-casts against - these warp meshes using the ray pattern provided in the configuration. + converted to warp meshes and stored in the :attr:`meshes` dictionary. The ray-caster then ray-casts + against these warp meshes using the ray pattern provided in the configuration. Compared to the default RayCaster, the MultiMeshRayCaster provides additional functionality and flexibility as an extension of the default RayCaster with the following enhancements: @@ -53,6 +54,15 @@ class MultiMeshRayCaster(RayCaster): (e.g., robot links, articulated bodies, or dynamic obstacles). - Memory-efficient caching : Avoids redundant memory usage by reusing mesh data across environments. + .. warning:: + **Known limitation (multi-mesh closest-hit resolution):** When two meshes produce a + hit at the exact same distance for a given ray, the ``atomic_min`` + equality-check + pattern in the raycasting kernel is not fully thread-safe. The hit *position* is always + correct, but auxiliary outputs (normals, face IDs, mesh IDs) may originate from + different meshes for the affected ray. This requires an exact floating-point tie and is + rare in practice. See `warp#1058 `_ for + upstream progress on a thread-safe ``atomic_min`` return value. + Example usage to raycast against the visual meshes of a robot (e.g. ANYmal): .. code-block:: python @@ -76,7 +86,10 @@ class MultiMeshRayCaster(RayCaster): cfg: MultiMeshRayCasterCfg """The configuration parameters.""" - mesh_offsets: dict[str, tuple[torch.Tensor, torch.Tensor]] = {} + mesh_offsets: ClassVar[dict[str, tuple[torch.Tensor, torch.Tensor]]] = {} + """Per-mesh position and orientation offsets relative to their physics views, shared across instances. + + Keys are prim path expressions; values are ``(pos_offset, ori_offset)`` tuples.""" mesh_views: ClassVar[dict[str, XformPrimView | physx.ArticulationView | physx.RigidBodyView]] = {} """A dictionary to store mesh views for raycasting, shared across all instances. @@ -90,33 +103,24 @@ def __init__(self, cfg: MultiMeshRayCasterCfg): Args: cfg: The configuration parameters. """ - # Initialize base class super().__init__(cfg) - # Create empty variables for storing output data self._num_meshes_per_env: dict[str, int] = {} - """Keeps track of the number of meshes per env for each ray_cast target. - Since we allow regex indexing (e.g. env_*/object_*) they can differ - """ self._raycast_targets_cfg: list[MultiMeshRayCasterCfg.RaycastTargetCfg] = [] for target in self.cfg.mesh_prim_paths: - # Legacy support for string targets. Treat them as global targets. if isinstance(target, str): self._raycast_targets_cfg.append(cfg.RaycastTargetCfg(prim_expr=target, track_mesh_transforms=False)) else: self._raycast_targets_cfg.append(target) - # Resolve regex namespace if set for cfg in self._raycast_targets_cfg: cfg.prim_expr = cfg.prim_expr.format(ENV_REGEX_NS="/World/envs/env_.*") - # overwrite the data class self._data = MultiMeshRayCasterData() def __str__(self) -> str: """Returns: A string containing information about the instance.""" - return ( f"Ray-caster @ '{self.cfg.prim_path}': \n" f"\tview type : {self._view.__class__}\n" @@ -133,9 +137,7 @@ def __str__(self) -> str: @property def data(self) -> MultiMeshRayCasterData: - # update sensors if needed self._update_outdated_buffers() - # return the data return self._data """ @@ -163,9 +165,8 @@ def _initialize_warp_meshes(self): """ multi_mesh_ids: dict[str, list[list[int]]] = {} for target_cfg in self._raycast_targets_cfg: - # target prim path to ray cast against target_prim_path = target_cfg.prim_expr - # # check if mesh already casted into warp mesh and skip if so. + # check if mesh already casted into warp mesh and skip if so. if target_prim_path in multi_mesh_ids: logger.warning( f"Mesh at target prim path '{target_prim_path}' already exists in the mesh cache. Duplicate entries" @@ -173,32 +174,29 @@ def _initialize_warp_meshes(self): ) continue - # find all matching prim paths to provided expression of the target target_prims = sim_utils.find_matching_prims(target_prim_path) if len(target_prims) == 0: raise RuntimeError(f"Failed to find a prim at path expression: {target_prim_path}") - # If only one prim is found, treat it as a global prim. - # Either it's a single global object (e.g. ground) or we are only using one env. is_global_prim = len(target_prims) == 1 loaded_vertices: list[np.ndarray | None] = [] wp_mesh_ids = [] for target_prim in target_prims: - # Reuse previously parsed shared mesh instance if possible. if target_cfg.is_shared and len(wp_mesh_ids) > 0: # Verify if this mesh has already been registered in an earlier environment. # Note, this check may fail, if the prim path is not following the env_.* pattern # Which (worst case) leads to parsing the mesh and skipping registering it at a later stage - curr_prim_base_path = re.sub(r"env_\d+", "env_0", str(target_prim.GetPath())) # - if curr_prim_base_path in MultiMeshRayCaster.meshes: - MultiMeshRayCaster.meshes[str(target_prim.GetPath())] = MultiMeshRayCaster.meshes[ - curr_prim_base_path - ] - # Reuse mesh imported by another ray-cast sensor (global cache). - if str(target_prim.GetPath()) in MultiMeshRayCaster.meshes: - wp_mesh_ids.append(MultiMeshRayCaster.meshes[str(target_prim.GetPath())].id) + curr_prim_base_path = re.sub(r"env_\d+", "env_0", str(target_prim.GetPath())) + base_key = (curr_prim_base_path, self._device) + if base_key in MultiMeshRayCaster.meshes: + MultiMeshRayCaster.meshes[(str(target_prim.GetPath()), self._device)] = ( + MultiMeshRayCaster.meshes[base_key] + ) + prim_key = (str(target_prim.GetPath()), self._device) + if prim_key in MultiMeshRayCaster.meshes: + wp_mesh_ids.append(MultiMeshRayCaster.meshes[prim_key].id) loaded_vertices.append(None) continue @@ -219,7 +217,6 @@ def _initialize_warp_meshes(self): trimesh_meshes = [] for mesh_prim in mesh_prims: - # check if valid if mesh_prim is None or not mesh_prim.IsValid(): raise RuntimeError(f"Invalid mesh prim path: {target_prim}") @@ -240,13 +237,11 @@ def _initialize_warp_meshes(self): transform[:3, 3] = relative_pos.numpy() mesh.apply_transform(transform) - # add to list of parsed meshes trimesh_meshes.append(mesh) if len(trimesh_meshes) == 1: trimesh_mesh = trimesh_meshes[0] elif target_cfg.merge_prim_meshes: - # combine all trimesh meshes into a single mesh trimesh_mesh = trimesh.util.concatenate(trimesh_meshes) else: raise RuntimeError( @@ -254,20 +249,17 @@ def _initialize_warp_meshes(self): " enable `merge_prim_meshes` in the configuration or specify each mesh separately." ) - # check if the mesh is already registered, if so only reference the mesh registered_idx = _registered_points_idx(trimesh_mesh.vertices, loaded_vertices) if registered_idx != -1 and self.cfg.reference_meshes: logger.info("Found a duplicate mesh, only reference the mesh.") - # Found a duplicate mesh, only reference the mesh. loaded_vertices.append(None) wp_mesh_ids.append(wp_mesh_ids[registered_idx]) else: loaded_vertices.append(trimesh_mesh.vertices) - wp_mesh = convert_to_warp_mesh(trimesh_mesh.vertices, trimesh_mesh.faces, device=self.device) - MultiMeshRayCaster.meshes[str(target_prim.GetPath())] = wp_mesh + wp_mesh = convert_to_warp_mesh(trimesh_mesh.vertices, trimesh_mesh.faces, device=self._device) + MultiMeshRayCaster.meshes[(str(target_prim.GetPath()), self._device)] = wp_mesh wp_mesh_ids.append(wp_mesh.id) - # print info if registered_idx != -1: logger.info(f"Found duplicate mesh for mesh prims under path '{target_prim.GetPath()}'.") else: @@ -277,12 +269,9 @@ def _initialize_warp_meshes(self): ) if is_global_prim: - # reference the mesh for each environment to ray cast against multi_mesh_ids[target_prim_path] = [wp_mesh_ids] * self._num_envs self._num_meshes_per_env[target_prim_path] = len(wp_mesh_ids) else: - # split up the meshes for each environment. Little bit ugly, since - # the current order is interleaved (env1_obj1, env1_obj2, env2_obj1, env2_obj2, ...) multi_mesh_ids[target_prim_path] = [] mesh_idx = 0 n_meshes_per_env = len(wp_mesh_ids) // self._num_envs @@ -296,22 +285,24 @@ def _initialize_warp_meshes(self): self._obtain_trackable_prim_view(target_prim_path) ) - # throw an error if no meshes are found if all([target_cfg.prim_expr not in multi_mesh_ids for target_cfg in self._raycast_targets_cfg]): raise RuntimeError( f"No meshes found for ray-casting! Please check the mesh prim paths: {self.cfg.mesh_prim_paths}" ) total_n_meshes_per_env = sum(self._num_meshes_per_env.values()) - self._mesh_positions_w = torch.zeros(self._num_envs, total_n_meshes_per_env, 3, device=self.device) - self._mesh_orientations_w = torch.zeros(self._num_envs, total_n_meshes_per_env, 4, device=self.device) + self._mesh_positions_w = wp.zeros((self._num_envs, total_n_meshes_per_env), dtype=wp.vec3, device=self.device) + self._mesh_orientations_w = wp.zeros( + (self._num_envs, total_n_meshes_per_env), dtype=wp.quat, device=self.device + ) + # Zero-copy torch views for writing from physics view results (torch tensors) + self._mesh_positions_w_torch = wp.to_torch(self._mesh_positions_w) + self._mesh_orientations_w_torch = wp.to_torch(self._mesh_orientations_w) - # Update the mesh positions and rotations mesh_idx = 0 for target_cfg in self._raycast_targets_cfg: n_meshes = self._num_meshes_per_env[target_cfg.prim_expr] - # update position of the target meshes pos_w, ori_w = [], [] for prim in sim_utils.find_matching_prims(target_cfg.prim_expr): translation, quat = sim_utils.resolve_prim_pose(prim) @@ -320,11 +311,10 @@ def _initialize_warp_meshes(self): pos_w = torch.tensor(pos_w, device=self.device, dtype=torch.float32).view(-1, n_meshes, 3) ori_w = torch.tensor(ori_w, device=self.device, dtype=torch.float32).view(-1, n_meshes, 4) - self._mesh_positions_w[:, mesh_idx : mesh_idx + n_meshes] = pos_w - self._mesh_orientations_w[:, mesh_idx : mesh_idx + n_meshes] = ori_w + self._mesh_positions_w_torch[:, mesh_idx : mesh_idx + n_meshes] = pos_w + self._mesh_orientations_w_torch[:, mesh_idx : mesh_idx + n_meshes] = ori_w mesh_idx += n_meshes - # flatten the list of meshes that are included in mesh_prim_paths of the specific ray caster multi_mesh_ids_flattened = [] for env_idx in range(self._num_envs): meshes_in_env = [] @@ -337,63 +327,109 @@ def _initialize_warp_meshes(self): for target_cfg in self._raycast_targets_cfg ] - # save a warp array with mesh ids that is passed to the raycast function self._mesh_ids_wp = wp.array2d(multi_mesh_ids_flattened, dtype=wp.uint64, device=self.device) def _initialize_rays_impl(self): super()._initialize_rays_impl() + # Persistent buffer for tracking closest-hit distance across meshes (for atomic_min) + self._ray_distance_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.float32, device=self._device) if self.cfg.update_mesh_ids: - self._data.ray_mesh_ids = torch.zeros( - self._num_envs, self.num_rays, 1, device=self.device, dtype=torch.int16 - ) - - def _update_buffers_impl(self, env_mask: wp.array): - """Fills the buffers of the sensor data.""" - env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) - if len(env_ids) == 0: - return - - self._update_ray_infos(env_ids) - - # Update the mesh positions and rotations + self._ray_mesh_id_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.int16, device=self._device) + # Zero-copy torch view with the trailing dim expected by consumers of ray_mesh_ids + self._data.ray_mesh_ids = wp.to_torch(self._ray_mesh_id_w).unsqueeze(-1) + else: + # Dummy 1×1 buffer so the kernel launch always has a valid array to bind + self._ray_mesh_id_w = wp.empty((1, 1), dtype=wp.int16, device=self._device) + # Persistent dummy buffers for unused kernel outputs; allocated once to avoid per-step allocations. + self._dummy_normal_w = wp.empty((1, 1), dtype=wp.vec3, device=self._device) + self._dummy_face_id_w = wp.empty((1, 1), dtype=wp.int32, device=self._device) + + def _update_mesh_transforms(self) -> None: + """Update world-frame mesh positions and orientations for dynamically tracked targets. + + Iterates over all tracked views and writes the current world poses into + ``_mesh_positions_w_torch`` and ``_mesh_orientations_w_torch``. Static (non-tracked) + targets are skipped; their initial poses were set during :meth:`_initialize_warp_meshes`. + """ mesh_idx = 0 for view, target_cfg in zip(self._mesh_views, self._raycast_targets_cfg): if not target_cfg.track_mesh_transforms: mesh_idx += self._num_meshes_per_env[target_cfg.prim_expr] continue - # update position of the target meshes pos_w, ori_w = obtain_world_pose_from_view(view, None) pos_w = pos_w.squeeze(0) if len(pos_w.shape) == 3 else pos_w ori_w = ori_w.squeeze(0) if len(ori_w.shape) == 3 else ori_w if target_cfg.prim_expr in MultiMeshRayCaster.mesh_offsets: pos_offset, ori_offset = MultiMeshRayCaster.mesh_offsets[target_cfg.prim_expr] - pos_w -= pos_offset - ori_w = quat_mul(ori_offset.expand(ori_w.shape[0], -1), ori_w) + pos_w, ori_w = combine_frame_transforms( + pos_w, + ori_w, + pos_offset.expand(pos_w.shape[0], -1), + ori_offset.expand(ori_w.shape[0], -1), + ) count = view.count - if count != 1: # Mesh is not global, i.e. we have different meshes for each env + if count != 1: count = count // self._num_envs pos_w = pos_w.view(self._num_envs, count, 3) ori_w = ori_w.view(self._num_envs, count, 4) - self._mesh_positions_w[:, mesh_idx : mesh_idx + count] = pos_w - self._mesh_orientations_w[:, mesh_idx : mesh_idx + count] = ori_w + self._mesh_positions_w_torch[:, mesh_idx : mesh_idx + count] = pos_w + self._mesh_orientations_w_torch[:, mesh_idx : mesh_idx + count] = ori_w mesh_idx += count - self._data.ray_hits_w[env_ids], _, _, _, mesh_ids = raycast_dynamic_meshes( - self._ray_starts_w[env_ids], - self._ray_directions_w[env_ids], - mesh_ids_wp=self._mesh_ids_wp, # list with shape num_envs x num_meshes_per_env - max_dist=self.cfg.max_distance, - mesh_positions_w=self._mesh_positions_w[env_ids], - mesh_orientations_w=self._mesh_orientations_w[env_ids], - return_mesh_id=self.cfg.update_mesh_ids, + def _update_buffers_impl(self, env_mask: wp.array): + """Fills the buffers of the sensor data.""" + self._update_ray_infos(env_mask) + self._update_mesh_transforms() + + n_meshes = self._mesh_ids_wp.shape[1] + + # Fill output and distance buffers with inf for masked environments + wp.launch( + fill_vec3_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._data._ray_hits_w], + device=self._device, + ) + wp.launch( + fill_float2d_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._ray_distance_w], + device=self._device, ) - if self.cfg.update_mesh_ids: - self._data.ray_mesh_ids[env_ids] = mesh_ids + # Ray-cast against all meshes; closest hit wins via atomic_min on ray_distance + wp.launch( + warp_kernels.raycast_dynamic_meshes_kernel, + dim=(n_meshes, self._num_envs, self.num_rays), + inputs=[ + env_mask, + self._mesh_ids_wp, + self._ray_starts_w, + self._ray_directions_w, + self._data._ray_hits_w, + self._ray_distance_w, + self._dummy_normal_w, + self._dummy_face_id_w, + self._ray_mesh_id_w, + self._mesh_positions_w, + self._mesh_orientations_w, + float(self.cfg.max_distance), + int(False), + int(False), + int(self.cfg.update_mesh_ids), + ], + device=self._device, + ) + + def _invalidate_initialize_callback(self, event): + """Invalidates the scene elements.""" + super()._invalidate_initialize_callback(event) + # clear mesh views so they are re-created on the next initialization + MultiMeshRayCaster.mesh_views.clear() def __del__(self): super().__del__() diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera.py b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera.py index a1be3160d99b..d5e084abb32e 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera.py @@ -5,15 +5,20 @@ from __future__ import annotations -from collections.abc import Sequence from typing import TYPE_CHECKING import torch import warp as wp import isaaclab.utils.math as math_utils -from isaaclab.utils.warp import raycast_dynamic_meshes - +from isaaclab.utils.warp import kernels as warp_kernels + +from .kernels import ( + CAMERA_RAYCAST_MAX_DIST, + compute_distance_to_image_plane_masked_kernel, + fill_float2d_masked_kernel, + fill_vec3_inf_kernel, +) from .multi_mesh_ray_caster import MultiMeshRayCaster from .multi_mesh_ray_caster_camera_data import MultiMeshRayCasterCameraData from .ray_cast_utils import obtain_world_pose_from_view @@ -85,136 +90,203 @@ def _create_buffers(self): ) def _initialize_rays_impl(self): - # Create all indices buffer + # NOTE: This method intentionally does NOT call super()._initialize_rays_impl() through the MRO + # chain. The intermediate classes (RayCasterCamera, MultiMeshRayCaster) use different internal + # buffer names and orderings that are incompatible with the camera's full init path: + # - RayCasterCamera creates single-mesh ray buffers (_ray_distance, _ray_normal_w, etc.) + # - MultiMeshRayCaster creates _ray_distance_w / _ray_mesh_id_w for multi-mesh use + # The camera replaces all of these with its own camera-named equivalents below. + # If either parent class gains new shared buffers, they must be added here explicitly. + + # Camera-specific bookkeeping buffers self._ALL_INDICES = torch.arange(self._view.count, device=self._device, dtype=torch.long) - # Create frame count buffer self._frame = torch.zeros(self._view.count, device=self._device, dtype=torch.long) - # create buffers + + # Build camera output buffers (intrinsics, image data, etc.) self._create_buffers() - # compute intrinsic matrices self._compute_intrinsic_matrices() - # compute ray stars and directions - self.ray_starts, self.ray_directions = self.cfg.pattern_cfg.func( + + # Compute local ray starts/directions from the camera pattern (torch, init-time only) + ray_starts_local, ray_directions_local = self.cfg.pattern_cfg.func( self.cfg.pattern_cfg, self._data.intrinsic_matrices, self._device ) - self.num_rays = self.ray_directions.shape[1] - # create buffer to store ray hits - self.ray_hits_w = torch.zeros(self._view.count, self.num_rays, 3, device=self._device) - # set offsets - quat_w = math_utils.convert_camera_frame_orientation_convention( - torch.tensor([self.cfg.offset.rot], device=self._device), origin=self.cfg.offset.convention, target="world" + self.num_rays = ray_directions_local.shape[1] + + # Store local (sensor-frame) ray arrays as torch tensors for per-env camera-convention rotation + self.ray_starts = ray_starts_local + self.ray_directions = ray_directions_local + + # Camera-frame offset: convert from cfg convention to world convention + quat_offset = math_utils.convert_camera_frame_orientation_convention( + torch.tensor([self.cfg.offset.rot], device=self._device), + origin=self.cfg.offset.convention, + target="world", ) - self._offset_quat = quat_w.repeat(self._view.count, 1) + self._offset_quat = quat_offset.repeat(self._view.count, 1) self._offset_pos = torch.tensor(list(self.cfg.offset.pos), device=self._device).repeat(self._view.count, 1) - self._data.quat_w = torch.zeros(self._view.count, 4, device=self.device) - self._data.pos_w = torch.zeros(self._view.count, 3, device=self.device) + # Camera pose buffers (torch, part of CameraData) + self._data.pos_w = torch.zeros(self._view.count, 3, device=self._device) + self._data.quat_w_world = torch.zeros(self._view.count, 4, device=self._device) + # Warp-backed camera orientation buffer for warp kernel calls; + # updated from self._data.quat_w_world in _update_ray_infos. + self._quat_w_wp = wp.zeros(self._view.count, dtype=wp.quatf, device=self._device) + self._quat_w_wp_torch = wp.to_torch(self._quat_w_wp) + + # Warp buffer for distance_to_image_plane output (if requested) + if "distance_to_image_plane" in self.cfg.data_types: + self._distance_to_image_plane_wp = wp.zeros( + (self._view.count, self.num_rays), dtype=wp.float32, device=self._device + ) + + # World-frame ray buffers: allocate as warp arrays first, then create zero-copy torch views. + # Keeping warp arrays as primary storage avoids lifetime issues when passing to kernels. + self._ray_starts_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + self._ray_directions_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + # Zero-copy torch views used for indexing and post-processing + self._ray_starts_w_torch = wp.to_torch(self._ray_starts_w) + self._ray_directions_w_torch = wp.to_torch(self._ray_directions_w) + + # Ray hit positions as a warp array; expose a torch view for debug visualisation + self._ray_hits_w_cam = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + self.ray_hits_w = wp.to_torch(self._ray_hits_w_cam) + + # Per-ray closest-hit distance for atomic_min across meshes + self._ray_distance_cam_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.float32, device=self._device) + + # Optional normal buffer (always allocated; filled only when "normals" is requested) + self._ray_normal_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) - self._ray_starts_w = torch.zeros(self._view.count, self.num_rays, 3, device=self.device) - self._ray_directions_w = torch.zeros(self._view.count, self.num_rays, 3, device=self.device) + # Mesh-id buffers from MultiMeshRayCaster._initialize_rays_impl + if self.cfg.update_mesh_ids: + self._ray_mesh_id_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.int16, device=self._device) + self._data.ray_mesh_ids = wp.to_torch(self._ray_mesh_id_w).unsqueeze(-1) + else: + self._ray_mesh_id_w = wp.empty((1, 1), dtype=wp.int16, device=self._device) + + # Dummy face-id buffer (not used by camera but required by kernel signature) + self._ray_face_id_w = wp.empty((1, 1), dtype=wp.int32, device=self._device) - def _update_ray_infos(self, env_ids: Sequence[int]): - """Updates the ray information buffers.""" + def _update_ray_infos(self, env_mask: wp.array): + """Updates camera poses and world-frame ray buffers for masked environments. + + Args: + env_mask: Boolean mask selecting which environments to update. Shape is (num_envs,). + """ + env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) + if len(env_ids) == 0: + return - # compute poses from current view + # Compute camera world poses by composing view pose with sensor offset pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids) pos_w, quat_w = math_utils.combine_frame_transforms( pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] ) - # update the data + # Store camera pose in CameraData (torch tensors) and warp-backed orientation buffer self._data.pos_w[env_ids] = pos_w self._data.quat_w_world[env_ids] = quat_w - self._data.quat_w_ros[env_ids] = quat_w + self._quat_w_wp_torch[env_ids] = quat_w - # note: full orientation is considered - ray_starts_w = math_utils.quat_apply(quat_w.repeat(1, self.num_rays), self.ray_starts[env_ids]) - ray_starts_w += pos_w.unsqueeze(1) - ray_directions_w = math_utils.quat_apply(quat_w.repeat(1, self.num_rays), self.ray_directions[env_ids]) + # Rotate local ray starts and directions into world frame using full camera orientation + quat_w_repeated = quat_w.repeat(1, self.num_rays).reshape(-1, 4) + ray_starts_local = self.ray_starts[env_ids].reshape(-1, 3) + ray_dirs_local = self.ray_directions[env_ids].reshape(-1, 3) - self._ray_starts_w[env_ids] = ray_starts_w - self._ray_directions_w[env_ids] = ray_directions_w + ray_starts_world = math_utils.quat_apply(quat_w_repeated, ray_starts_local).reshape( + len(env_ids), self.num_rays, 3 + ) + ray_starts_world += pos_w.unsqueeze(1) + ray_dirs_world = math_utils.quat_apply(quat_w_repeated, ray_dirs_local).reshape(len(env_ids), self.num_rays, 3) + + # Write back into the warp-backed buffers via zero-copy torch views + self._ray_starts_w_torch[env_ids] = ray_starts_world + self._ray_directions_w_torch[env_ids] = ray_dirs_world def _update_buffers_impl(self, env_mask: wp.array): """Fills the buffers of the sensor data.""" env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) if len(env_ids) == 0: return - self._update_ray_infos(env_ids) - # increment frame count + self._update_ray_infos(env_mask) + + # Increment frame count for updated environments self._frame[env_ids] += 1 - # Update the mesh positions and rotations - mesh_idx = 0 - for view, target_cfg in zip(self._mesh_views, self._raycast_targets_cfg): - if not target_cfg.track_mesh_transforms: - mesh_idx += self._num_meshes_per_env[target_cfg.prim_expr] - continue - - # update position of the target meshes - pos_w, ori_w = obtain_world_pose_from_view(view, None) - pos_w = pos_w.squeeze(0) if len(pos_w.shape) == 3 else pos_w - ori_w = ori_w.squeeze(0) if len(ori_w.shape) == 3 else ori_w - - if target_cfg.prim_expr in MultiMeshRayCaster.mesh_offsets: - pos_offset, ori_offset = MultiMeshRayCaster.mesh_offsets[target_cfg.prim_expr] - pos_w -= pos_offset - ori_w = math_utils.quat_mul(ori_offset.expand(ori_w.shape[0], -1), ori_w) - - count = view.count - if count != 1: # Mesh is not global, i.e. we have different meshes for each env - count = count // self._num_envs - pos_w = pos_w.view(self._num_envs, count, 3) - ori_w = ori_w.view(self._num_envs, count, 4) - - self._mesh_positions_w[:, mesh_idx : mesh_idx + count] = pos_w - self._mesh_orientations_w[:, mesh_idx : mesh_idx + count] = ori_w - mesh_idx += count - - # ray cast and store the hits - self.ray_hits_w[env_ids], ray_depth, ray_normal, _, ray_mesh_ids = raycast_dynamic_meshes( - self._ray_starts_w[env_ids], - self._ray_directions_w[env_ids], - mesh_ids_wp=self._mesh_ids_wp, # list with shape num_envs x num_meshes_per_env - max_dist=self.cfg.max_distance, - mesh_positions_w=self._mesh_positions_w[env_ids], - mesh_orientations_w=self._mesh_orientations_w[env_ids], - return_distance=any( - [name in self.cfg.data_types for name in ["distance_to_image_plane", "distance_to_camera"]] - ), - return_normal="normals" in self.cfg.data_types, - return_mesh_id=self.cfg.update_mesh_ids, + self._update_mesh_transforms() + + n_meshes = self._mesh_ids_wp.shape[1] + return_normal = "normals" in self.cfg.data_types + + # Fill ray hit and distance buffers with inf for masked environments + wp.launch( + fill_vec3_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._ray_hits_w_cam], + device=self._device, + ) + wp.launch( + fill_float2d_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._ray_distance_cam_w], + device=self._device, + ) + if return_normal: + wp.launch( + fill_vec3_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._ray_normal_w], + device=self._device, + ) + + # Ray-cast against all meshes; closest hit wins via atomic_min on ray_distance + wp.launch( + warp_kernels.raycast_dynamic_meshes_kernel, + dim=(n_meshes, self._num_envs, self.num_rays), + inputs=[ + env_mask, + self._mesh_ids_wp, + self._ray_starts_w, + self._ray_directions_w, + self._ray_hits_w_cam, + self._ray_distance_cam_w, + self._ray_normal_w, + self._ray_face_id_w, + self._ray_mesh_id_w, + self._mesh_positions_w, + self._mesh_orientations_w, + float(CAMERA_RAYCAST_MAX_DIST), + int(return_normal), + int(False), + int(self.cfg.update_mesh_ids), + ], + device=self._device, ) - # update output buffers if "distance_to_image_plane" in self.cfg.data_types: - # note: data is in camera frame so we only take the first component (z-axis of camera frame) - distance_to_image_plane = ( - math_utils.quat_apply( - math_utils.quat_inv(self._data.quat_w_world[env_ids]).repeat(1, self.num_rays), - (ray_depth[:, :, None] * self._ray_directions_w[env_ids]), - ) - )[:, :, 0] - # apply the maximum distance after the transformation - if self.cfg.depth_clipping_behavior == "max": - distance_to_image_plane = torch.clip(distance_to_image_plane, max=self.cfg.max_distance) - distance_to_image_plane[torch.isnan(distance_to_image_plane)] = self.cfg.max_distance - elif self.cfg.depth_clipping_behavior == "zero": - distance_to_image_plane[distance_to_image_plane > self.cfg.max_distance] = 0.0 - distance_to_image_plane[torch.isnan(distance_to_image_plane)] = 0.0 - self._data.output["distance_to_image_plane"][env_ids] = distance_to_image_plane.view( - -1, *self.image_shape, 1 + wp.launch( + compute_distance_to_image_plane_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, self._quat_w_wp, self._ray_distance_cam_w, self._ray_directions_w], + outputs=[self._distance_to_image_plane_wp], + device=self._device, ) + # Apply depth clipping on the intermediate buffer (leaves _ray_distance_cam_w unmodified) + self._apply_depth_clipping(env_mask, self._distance_to_image_plane_wp) + d2ip_torch = wp.to_torch(self._distance_to_image_plane_wp) + self._data.output["distance_to_image_plane"][env_ids] = d2ip_torch[env_ids].view(-1, *self.image_shape, 1) if "distance_to_camera" in self.cfg.data_types: - if self.cfg.depth_clipping_behavior == "max": - ray_depth = torch.clip(ray_depth, max=self.cfg.max_distance) - elif self.cfg.depth_clipping_behavior == "zero": - ray_depth[ray_depth > self.cfg.max_distance] = 0.0 - self._data.output["distance_to_camera"][env_ids] = ray_depth.view(-1, *self.image_shape, 1) + # d2ip (if requested) was computed before this block so _ray_distance_cam_w is still unclipped. + self._apply_depth_clipping(env_mask, self._ray_distance_cam_w) + ray_dist_torch = wp.to_torch(self._ray_distance_cam_w) + self._data.output["distance_to_camera"][env_ids] = ray_dist_torch[env_ids].view(-1, *self.image_shape, 1) - if "normals" in self.cfg.data_types: - self._data.output["normals"][env_ids] = ray_normal.view(-1, *self.image_shape, 3) + if return_normal: + ray_normal_torch = wp.to_torch(self._ray_normal_w) + self._data.output["normals"][env_ids] = ray_normal_torch[env_ids].view(-1, *self.image_shape, 3) if self.cfg.update_mesh_ids: - self._data.image_mesh_ids[env_ids] = ray_mesh_ids.view(-1, *self.image_shape, 1) + self._data.image_mesh_ids[env_ids] = wp.to_torch(self._ray_mesh_id_w)[env_ids].view( + -1, *self.image_shape, 1 + ) diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera_data.py b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera_data.py index d2f26abdbf47..21338f0a0616 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera_data.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera_data.py @@ -9,11 +9,15 @@ from isaaclab.sensors.camera import CameraData -from .ray_caster_data import RayCasterData +class MultiMeshRayCasterCameraData(CameraData): + """Data container for the multi-mesh ray-cast camera sensor. -class MultiMeshRayCasterCameraData(CameraData, RayCasterData): - """Data container for the multi-mesh ray-cast sensor.""" + This class extends :class:`CameraData` with additional mesh-id information. + It does not inherit from :class:`RayCasterData` because the camera variant + manages its own torch-based pose and hit buffers independently from the + warp-native :class:`RayCasterData`. + """ image_mesh_ids: torch.Tensor = None """The mesh ids of the image pixels. diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py index 731d57f1638f..89cc9aaa674f 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py @@ -13,6 +13,7 @@ import torch import warp as wp +import omni.physics.tensors.impl.api as physx from pxr import Gf, Usd, UsdGeom, UsdPhysics import isaaclab.sim as sim_utils @@ -20,17 +21,20 @@ from isaaclab.markers import VisualizationMarkers from isaaclab.sim.views import XformPrimView from isaaclab.terrains.trimesh.utils import make_plane -from isaaclab.utils.math import quat_apply, quat_apply_yaw -from isaaclab.utils.warp import convert_to_warp_mesh, raycast_mesh +from isaaclab.utils.warp import convert_to_warp_mesh +from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel from ..sensor_base import SensorBase -from .ray_cast_utils import obtain_world_pose_from_view +from .kernels import ( + apply_z_drift_kernel, + fill_vec3_inf_kernel, + update_ray_caster_kernel, +) from .ray_caster_data import RayCasterData if TYPE_CHECKING: from .ray_caster_cfg import RayCasterCfg -# import logger logger = logging.getLogger(__name__) @@ -42,8 +46,8 @@ class RayCaster(SensorBase): a set of meshes with a given ray pattern. The meshes are parsed from the list of primitive paths provided in the configuration. These are then - converted to warp meshes and stored in the `warp_meshes` list. The ray-caster then ray-casts against - these warp meshes using the ray pattern provided in the configuration. + converted to warp meshes and stored in the :attr:`meshes` dictionary. The ray-caster then ray-casts + against these warp meshes using the ray pattern provided in the configuration. .. note:: Currently, only static meshes are supported. Extending the warp mesh to support dynamic meshes @@ -53,11 +57,12 @@ class RayCaster(SensorBase): cfg: RayCasterCfg """The configuration parameters.""" - # Class variables to share meshes across instances - meshes: ClassVar[dict[str, wp.Mesh]] = {} + meshes: ClassVar[dict[tuple[str, str], wp.Mesh]] = {} """A dictionary to store warp meshes for raycasting, shared across all instances. - The keys correspond to the prim path for the meshes, and values are the corresponding warp Mesh objects.""" + The keys are ``(prim_path, device)`` tuples and values are the corresponding warp Mesh objects. + Including the device in the key prevents a mesh created on one device (e.g. CPU) from being + reused by a kernel running on a different device (e.g. CUDA).""" _instance_count: ClassVar[int] = 0 """A counter to track the number of RayCaster instances, used to manage class variable lifecycle.""" @@ -68,9 +73,7 @@ def __init__(self, cfg: RayCasterCfg): cfg: The configuration parameters. """ RayCaster._instance_count += 1 - # Initialize base class super().__init__(cfg) - # Create empty variables for storing output data self._data = RayCasterData() def __str__(self) -> str: @@ -116,10 +119,10 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None else: env_ids = slice(None) num_envs_ids = self._view.count - # resample the drift + # resample drift (uses torch views for indexing) r = torch.empty(num_envs_ids, 3, device=self.device) self.drift[env_ids] = r.uniform_(*self.cfg.drift_range) - # resample the height drift + # resample the ray cast drift range_list = [self.cfg.ray_cast_drift_range.get(key, (0.0, 0.0)) for key in ["x", "y", "z"]] ranges = torch.tensor(range_list, device=self.device) self.ray_cast_drift[env_ids] = math_utils.sample_uniform( @@ -133,7 +136,6 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None def _initialize_impl(self): super()._initialize_impl() # obtain global simulation view - self._physics_sim_view = sim_utils.SimulationContext.instance().physics_manager.get_physics_sim_view() prim = sim_utils.find_first_matching_prim(self.cfg.prim_path) if prim is None: @@ -144,9 +146,39 @@ def _initialize_impl(self): self._view, self._offset = self._obtain_trackable_prim_view(self.cfg.prim_path) + # Convert offsets to warp (zero-copy from existing torch tensors). + # Store the contiguous tensors explicitly so they are not garbage-collected while + # the wp.array views (_offset_pos_wp / _offset_quat_wp) are alive. If the tensor + # returned by .contiguous() is a temporary copy (non-contiguous input), the warp + # view would otherwise point to freed memory once GC reclaims it. + self._offset_pos_contiguous = self._offset[0].contiguous() + self._offset_quat_contiguous = self._offset[1].contiguous() + self._offset_pos_wp = wp.from_torch(self._offset_pos_contiguous, dtype=wp.vec3f) + self._offset_quat_wp = wp.from_torch(self._offset_quat_contiguous, dtype=wp.quatf) + + # Handle deprecated attach_yaw_only at init time + if self.cfg.attach_yaw_only is not None: + msg = ( + "Raycaster attribute 'attach_yaw_only' property will be deprecated in a future release." + " Please use the parameter 'ray_alignment' instead." + ) + if self.cfg.attach_yaw_only: + self.cfg.ray_alignment = "yaw" + msg += " Setting ray_alignment to 'yaw'." + else: + self.cfg.ray_alignment = "base" + msg += " Setting ray_alignment to 'base'." + logger.warning(msg) + self.cfg.attach_yaw_only = None + + # Resolve alignment mode to integer constant for kernel dispatch + alignment_map = {"world": 0, "yaw": 1, "base": 2} + if self.cfg.ray_alignment not in alignment_map: + raise RuntimeError(f"Unsupported ray_alignment type: {self.cfg.ray_alignment}.") + self._alignment_mode = alignment_map[self.cfg.ray_alignment] + # load the meshes by parsing the stage self._initialize_warp_meshes() - # initialize the ray start and directions self._initialize_rays_impl() def _initialize_warp_meshes(self): @@ -158,168 +190,193 @@ def _initialize_warp_meshes(self): # read prims to ray-cast for mesh_prim_path in self.cfg.mesh_prim_paths: - # check if mesh already casted into warp mesh - if mesh_prim_path in RayCaster.meshes: + mesh_key = (mesh_prim_path, self._device) + if mesh_key in RayCaster.meshes: continue - # check if the prim is a plane - handle PhysX plane as a special case - # if a plane exists then we need to create an infinite mesh that is a plane mesh_prim = sim_utils.get_first_matching_child_prim( mesh_prim_path, lambda prim: prim.GetTypeName() == "Plane" ) - # if we did not find a plane then we need to read the mesh if mesh_prim is None: - # obtain the mesh prim mesh_prim = sim_utils.get_first_matching_child_prim( mesh_prim_path, lambda prim: prim.GetTypeName() == "Mesh" ) - # check if valid if mesh_prim is None or not mesh_prim.IsValid(): raise RuntimeError(f"Invalid mesh prim path: {mesh_prim_path}") - # cast into UsdGeomMesh mesh_prim = UsdGeom.Mesh(mesh_prim) - # read the vertices and faces points = np.asarray(mesh_prim.GetPointsAttr().Get()) - # Get world transform using pure USD (UsdGeom.Xformable) xformable = UsdGeom.Xformable(mesh_prim) world_transform: Gf.Matrix4d = xformable.ComputeLocalToWorldTransform(Usd.TimeCode.Default()) transform_matrix = np.array(world_transform).T points = np.matmul(points, transform_matrix[:3, :3].T) points += transform_matrix[:3, 3] indices = np.asarray(mesh_prim.GetFaceVertexIndicesAttr().Get()) - wp_mesh = convert_to_warp_mesh(points, indices, device=self.device) - # print info + wp_mesh = convert_to_warp_mesh(points, indices, device=self._device) logger.info( f"Read mesh prim: {mesh_prim.GetPath()} with {len(points)} vertices and {len(indices)} faces." ) else: mesh = make_plane(size=(2e6, 2e6), height=0.0, center_zero=True) - wp_mesh = convert_to_warp_mesh(mesh.vertices, mesh.faces, device=self.device) - # print info + wp_mesh = convert_to_warp_mesh(mesh.vertices, mesh.faces, device=self._device) logger.info(f"Created infinite plane mesh prim: {mesh_prim.GetPath()}.") - # add the warp mesh to the list - RayCaster.meshes[mesh_prim_path] = wp_mesh + RayCaster.meshes[mesh_key] = wp_mesh - # throw an error if no meshes are found - if all([mesh_prim_path not in RayCaster.meshes for mesh_prim_path in self.cfg.mesh_prim_paths]): + if all((mesh_prim_path, self._device) not in RayCaster.meshes for mesh_prim_path in self.cfg.mesh_prim_paths): raise RuntimeError( f"No meshes found for ray-casting! Please check the mesh prim paths: {self.cfg.mesh_prim_paths}" ) def _initialize_rays_impl(self): - # compute ray stars and directions - self.ray_starts, self.ray_directions = self.cfg.pattern_cfg.func(self.cfg.pattern_cfg, self._device) - self.num_rays = len(self.ray_directions) - # apply offset transformation to the rays + # Compute ray starts and directions from pattern (torch, init-time only) + ray_starts_torch, ray_directions_torch = self.cfg.pattern_cfg.func(self.cfg.pattern_cfg, self._device) + self.num_rays = len(ray_directions_torch) + + # Apply sensor offset rotation/position to local ray pattern offset_pos = torch.tensor(list(self.cfg.offset.pos), device=self._device) offset_quat = torch.tensor(list(self.cfg.offset.rot), device=self._device) - self.ray_directions = quat_apply(offset_quat.repeat(len(self.ray_directions), 1), self.ray_directions) - self.ray_starts += offset_pos - # repeat the rays for each sensor - self.ray_starts = self.ray_starts.repeat(self._view.count, 1, 1) - self.ray_directions = self.ray_directions.repeat(self._view.count, 1, 1) - # prepare drift - self.drift = torch.zeros(self._view.count, 3, device=self.device) - self.ray_cast_drift = torch.zeros(self._view.count, 3, device=self.device) - # fill the data buffer - self._data.pos_w = torch.zeros(self._view.count, 3, device=self.device) - self._data.quat_w = torch.zeros(self._view.count, 4, device=self.device) - self._data.ray_hits_w = torch.zeros(self._view.count, self.num_rays, 3, device=self.device) - self._ray_starts_w = torch.zeros(self._view.count, self.num_rays, 3, device=self.device) - self._ray_directions_w = torch.zeros(self._view.count, self.num_rays, 3, device=self.device) - - def _update_ray_infos(self, env_ids: Sequence[int]): - """Updates the ray information buffers.""" - - pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids) - pos_w, quat_w = math_utils.combine_frame_transforms( - pos_w, quat_w, self._offset[0][env_ids], self._offset[1][env_ids] + ray_directions_torch = math_utils.quat_apply( + offset_quat.repeat(len(ray_directions_torch), 1), ray_directions_torch ) - # apply drift to ray starting position in world frame - pos_w += self.drift[env_ids] - # store the poses - self._data.pos_w[env_ids] = pos_w - self._data.quat_w[env_ids] = quat_w + ray_starts_torch += offset_pos - # check if user provided attach_yaw_only flag - if self.cfg.attach_yaw_only is not None: - msg = ( - "Raycaster attribute 'attach_yaw_only' property will be deprecated in a future release." - " Please use the parameter 'ray_alignment' instead." - ) - # set ray alignment to yaw - if self.cfg.attach_yaw_only: - self.cfg.ray_alignment = "yaw" - msg += " Setting ray_alignment to 'yaw'." - else: - self.cfg.ray_alignment = "base" - msg += " Setting ray_alignment to 'base'." - # log the warning - logger.warning(msg) - # ray cast based on the sensor poses - if self.cfg.ray_alignment == "world": - # apply horizontal drift to ray starting position in ray caster frame - pos_w[:, 0:2] += self.ray_cast_drift[env_ids, 0:2] - # no rotation is considered and directions are not rotated - ray_starts_w = self.ray_starts[env_ids] - ray_starts_w += pos_w.unsqueeze(1) - ray_directions_w = self.ray_directions[env_ids] - elif self.cfg.ray_alignment == "yaw": - # apply horizontal drift to ray starting position in ray caster frame - pos_w[:, 0:2] += quat_apply_yaw(quat_w, self.ray_cast_drift[env_ids])[:, 0:2] - # only yaw orientation is considered and directions are not rotated - ray_starts_w = quat_apply_yaw(quat_w.repeat(1, self.num_rays), self.ray_starts[env_ids]) - ray_starts_w += pos_w.unsqueeze(1) - ray_directions_w = self.ray_directions[env_ids] - elif self.cfg.ray_alignment == "base": - # apply horizontal drift to ray starting position in ray caster frame - pos_w[:, 0:2] += quat_apply(quat_w, self.ray_cast_drift[env_ids])[:, 0:2] - # full orientation is considered - ray_starts_w = quat_apply(quat_w.repeat(1, self.num_rays), self.ray_starts[env_ids]) - ray_starts_w += pos_w.unsqueeze(1) - ray_directions_w = quat_apply(quat_w.repeat(1, self.num_rays), self.ray_directions[env_ids]) - else: - raise RuntimeError(f"Unsupported ray_alignment type: {self.cfg.ray_alignment}.") + # Repeat for each environment + ray_starts_torch = ray_starts_torch.repeat(self._view.count, 1, 1) + ray_directions_torch = ray_directions_torch.repeat(self._view.count, 1, 1) + + # Create warp arrays from the init-time torch data + # The warp arrays own the memory; torch views provide backward-compat indexing + self._ray_starts_local = wp.from_torch(ray_starts_torch.contiguous(), dtype=wp.vec3f) + self._ray_directions_local = wp.from_torch(ray_directions_torch.contiguous(), dtype=wp.vec3f) + + # Torch views (same attribute names as before for subclass compatibility) + self.ray_starts = wp.to_torch(self._ray_starts_local) + self.ray_directions = wp.to_torch(self._ray_directions_local) + + # Drift buffers (warp-owned, torch views for reset indexing) + self._drift = wp.zeros(self._view.count, dtype=wp.vec3f, device=self._device) + self._ray_cast_drift = wp.zeros(self._view.count, dtype=wp.vec3f, device=self._device) + self.drift = wp.to_torch(self._drift) + self.ray_cast_drift = wp.to_torch(self._ray_cast_drift) + + # World-frame ray buffers + self._ray_starts_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + self._ray_directions_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + + # Torch views for subclass compatibility + self._ray_starts_w_torch = wp.to_torch(self._ray_starts_w) + self._ray_directions_w_torch = wp.to_torch(self._ray_directions_w) - self._ray_starts_w[env_ids] = ray_starts_w - self._ray_directions_w[env_ids] = ray_directions_w + # Data buffers + self._data.create_buffers(self._view.count, self.num_rays, self._device) + + # Dummy distance/normal buffers required by the merged raycast_mesh_masked_kernel signature. + # Sized (1, 1) even though the kernel is launched at (num_envs, num_rays): the kernel only + # writes to these buffers when return_distance==1 or return_normal==1 respectively, and + # RayCaster always passes 0 for both flags. If those flags are ever enabled here, these + # buffers must be resized to (num_envs, num_rays) to avoid an out-of-bounds write. + self._dummy_ray_distance = wp.empty((1, 1), dtype=wp.float32, device=self._device) + self._dummy_ray_normal = wp.empty((1, 1), dtype=wp.vec3f, device=self._device) + + def _get_view_transforms_wp(self) -> wp.array: + """Get world transforms from the physics view as a warp array. + + Returns: + Warp array of ``wp.transformf`` with shape (num_envs,). + """ + if isinstance(self._view, XformPrimView): + # XformPrimView.get_world_poses() returns quaternions in (x, y, z, w) convention, + # which matches the wp.transformf layout (translation then xyzw quaternion). + pos_w, quat_w = self._view.get_world_poses() + poses = torch.cat([pos_w, quat_w], dim=-1).contiguous() + return wp.from_torch(poses).view(wp.transformf) + elif isinstance(self._view, physx.ArticulationView): + return self._view.get_root_transforms().view(wp.transformf) + elif isinstance(self._view, physx.RigidBodyView): + return self._view.get_transforms().view(wp.transformf) + else: + raise NotImplementedError(f"Cannot get transforms for view type '{type(self._view)}'.") + + def _update_ray_infos(self, env_mask: wp.array): + """Updates sensor poses and ray world-frame buffers via a single warp kernel.""" + transforms = self._get_view_transforms_wp() + + wp.launch( + update_ray_caster_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + transforms, + env_mask, + self._offset_pos_wp, + self._offset_quat_wp, + self._drift, + self._ray_cast_drift, + self._ray_starts_local, + self._ray_directions_local, + self._alignment_mode, + ], + outputs=[ + self._data._pos_w, + self._data._quat_w, + self._ray_starts_w, + self._ray_directions_w, + ], + device=self._device, + ) def _update_buffers_impl(self, env_mask: wp.array): """Fills the buffers of the sensor data.""" - env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) - if len(env_ids) == 0: - return - self._update_ray_infos(env_ids) + self._update_ray_infos(env_mask) + + # Fill ray hits with inf before raycasting + wp.launch( + fill_vec3_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._data._ray_hits_w], + device=self._device, + ) - # ray cast and store the hits - # TODO: Make this work for multiple meshes? - self._data.ray_hits_w[env_ids] = raycast_mesh( - self._ray_starts_w[env_ids], - self._ray_directions_w[env_ids], - max_dist=self.cfg.max_distance, - mesh=RayCaster.meshes[self.cfg.mesh_prim_paths[0]], - )[0] + # Ray-cast against the mesh + wp.launch( + raycast_mesh_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + RayCaster.meshes[(self.cfg.mesh_prim_paths[0], self._device)].id, + env_mask, + self._ray_starts_w, + self._ray_directions_w, + float(self.cfg.max_distance), + int(False), # return_distance: not needed by RayCaster + int(False), # return_normal: not needed by RayCaster + self._data._ray_hits_w, + self._dummy_ray_distance, + self._dummy_ray_normal, + ], + device=self._device, + ) - # apply vertical drift to ray starting position in ray caster frame - self._data.ray_hits_w[env_ids, :, 2] += self.ray_cast_drift[env_ids, 2].unsqueeze(-1) + # Apply vertical drift to ray hits + wp.launch( + apply_z_drift_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, self._ray_cast_drift, self._data._ray_hits_w], + device=self._device, + ) def _set_debug_vis_impl(self, debug_vis: bool): - # set visibility of markers - # note: parent only deals with callbacks. not their visibility if debug_vis: if not hasattr(self, "ray_visualizer"): self.ray_visualizer = VisualizationMarkers(self.cfg.visualizer_cfg) - # set their visibility to true self.ray_visualizer.set_visibility(True) else: if hasattr(self, "ray_visualizer"): self.ray_visualizer.set_visibility(False) def _debug_vis_callback(self, event): - if self._data.ray_hits_w is None: + if self._data._ray_hits_w is None: return + ray_hits_torch = wp.to_torch(self._data._ray_hits_w) # remove possible inf values - viz_points = self._data.ray_hits_w.reshape(-1, 3) + viz_points = ray_hits_torch.reshape(-1, 3) viz_points = viz_points[~torch.any(torch.isinf(viz_points), dim=1)] # if no points to visualize, skip @@ -334,8 +391,8 @@ def _debug_vis_callback(self, event): def _obtain_trackable_prim_view( self, target_prim_path: str - ) -> tuple[XformPrimView | any, tuple[torch.Tensor, torch.Tensor]]: - """Obtain a prim view that can be used to track the pose of the parget prim. + ) -> tuple[XformPrimView | physx.ArticulationView | physx.RigidBodyView, tuple[torch.Tensor, torch.Tensor]]: + """Obtain a prim view that can be used to track the pose of the target prim. The target prim path is a regex expression that matches one or more mesh prims. While we can track its pose directly using XFormPrim, this is not efficient and can be slow. Instead, we create a prim view @@ -362,13 +419,11 @@ def _obtain_trackable_prim_view( prim_view = None while prim_view is None: - # TODO: Need to handle the case where API is present but it is disabled if current_prim.HasAPI(UsdPhysics.ArticulationRootAPI): prim_view = self._physics_sim_view.create_articulation_view(current_path_expr.replace(".*", "*")) logger.info(f"Created articulation view for mesh prim at path: {target_prim_path}") break - # TODO: Need to handle the case where API is present but it is disabled if current_prim.HasAPI(UsdPhysics.RigidBodyAPI): prim_view = self._physics_sim_view.create_rigid_body_view(current_path_expr.replace(".*", "*")) logger.info(f"Created rigid body view for mesh prim at path: {target_prim_path}") @@ -387,7 +442,6 @@ def _obtain_trackable_prim_view( ) break - # switch the current prim to the parent prim current_prim = new_root_prim # obtain the relative transforms between target prim and the view prims @@ -417,9 +471,7 @@ def _obtain_trackable_prim_view( def _invalidate_initialize_callback(self, event): """Invalidates the scene elements.""" - # call parent super()._invalidate_initialize_callback(event) - # set all existing views to None to invalidate them self._view = None def __del__(self): diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py index c27f470dcfc0..b9f53ea8c491 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py @@ -16,8 +16,17 @@ import isaaclab.utils.math as math_utils from isaaclab.sensors.camera import CameraData -from isaaclab.utils.warp import raycast_mesh - +from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel + +from .kernels import ( + ALIGNMENT_BASE, + CAMERA_RAYCAST_MAX_DIST, + apply_depth_clipping_masked_kernel, + compute_distance_to_image_plane_masked_kernel, + fill_float2d_masked_kernel, + fill_vec3_inf_kernel, + update_ray_caster_kernel, +) from .ray_cast_utils import obtain_world_pose_from_view from .ray_caster import RayCaster @@ -143,6 +152,13 @@ def set_intrinsic_matrices( self.ray_starts[env_ids], self.ray_directions[env_ids] = self.cfg.pattern_cfg.func( self.cfg.pattern_cfg, self._data.intrinsic_matrices[env_ids], self._device ) + # Refresh warp views of local ray buffers; .contiguous() may produce a copy so we store + # the contiguous tensors explicitly to prevent GC while the warp views are alive. + if hasattr(self, "_ray_starts_local"): + self._ray_starts_contiguous = self.ray_starts.contiguous() + self._ray_directions_contiguous = self.ray_directions.contiguous() + self._ray_starts_local = wp.from_torch(self._ray_starts_contiguous, dtype=wp.vec3f) + self._ray_directions_local = wp.from_torch(self._ray_directions_contiguous, dtype=wp.vec3f) def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None = None): # reset the timestamps @@ -179,7 +195,7 @@ def set_world_poses( - :obj:`"ros"` - forward axis: +Z - up axis -Y - Offset is applied in the ROS convention - :obj:`"world"` - forward axis: +X - up axis +Z - Offset is applied in the World Frame convention - See :meth:`isaaclab.utils.maths.convert_camera_frame_orientation_convention` for more details + See :meth:`isaaclab.utils.math.convert_camera_frame_orientation_convention` for more details on the conventions. Args: @@ -224,7 +240,7 @@ def set_world_poses_from_view( """Set the poses of the camera from the eye position and look-at target position. Args: - eyes: The positions of the camera's eye. Shape is N, 3). + eyes: The positions of the camera's eye. Shape is (N, 3). targets: The target locations to look at. Shape is (N, 3). env_ids: A sensor ids to manipulate. Defaults to None, which means all sensor indices. @@ -253,100 +269,243 @@ def _initialize_rays_impl(self): self._create_buffers() # compute intrinsic matrices self._compute_intrinsic_matrices() - # compute ray stars and directions + # compute ray starts and directions self.ray_starts, self.ray_directions = self.cfg.pattern_cfg.func( self.cfg.pattern_cfg, self._data.intrinsic_matrices, self._device ) self.num_rays = self.ray_directions.shape[1] - # create buffer to store ray hits - self.ray_hits_w = torch.zeros(self._view.count, self.num_rays, 3, device=self._device) - # set offsets + + # Offset buffers: warp-primary so the kernel always sees the current values without re-wrapping. + # Zero-copy torch views (_offset_pos, _offset_quat) are used by set_world_poses for indexed writes. + self._offset_pos_wp = wp.zeros(self._view.count, dtype=wp.vec3f, device=self._device) + self._offset_quat_wp = wp.zeros(self._view.count, dtype=wp.quatf, device=self._device) + self._offset_pos = wp.to_torch(self._offset_pos_wp) + self._offset_quat = wp.to_torch(self._offset_quat_wp) + # Initialize from config quat_w = math_utils.convert_camera_frame_orientation_convention( torch.tensor([self.cfg.offset.rot], device=self._device), origin=self.cfg.offset.convention, target="world" ) - self._offset_quat = quat_w.repeat(self._view.count, 1) - self._offset_pos = torch.tensor(list(self.cfg.offset.pos), device=self._device).repeat(self._view.count, 1) + self._offset_pos[:] = torch.tensor(list(self.cfg.offset.pos), device=self._device) + self._offset_quat[:] = quat_w + + # Warp buffers for world-frame rays (used by update kernel) + self._ray_starts_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + self._ray_directions_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + + # Warp views for ray_starts and ray_directions (from torch tensors returned by pattern_cfg.func) + # These are (num_envs, num_rays, 3) torch tensors; wrap as warp vec3f arrays. + # Store contiguous tensors explicitly so they are not garbage-collected while the + # warp views are alive (mirrors the pattern in RayCaster._initialize_impl). + self._ray_starts_contiguous = self.ray_starts.contiguous() + self._ray_directions_contiguous = self.ray_directions.contiguous() + self._ray_starts_local = wp.from_torch(self._ray_starts_contiguous, dtype=wp.vec3f) + self._ray_directions_local = wp.from_torch(self._ray_directions_contiguous, dtype=wp.vec3f) + + # Wrap the torch drift buffers (created in _create_buffers) as warp arrays (zero-copy). + # Cameras do not apply positional drift, so these remain zero. + self._drift_contiguous = self.drift.contiguous() + self._ray_cast_drift_contiguous = self.ray_cast_drift.contiguous() + self._drift = wp.from_torch(self._drift_contiguous, dtype=wp.vec3f) + self._ray_cast_drift = wp.from_torch(self._ray_cast_drift_contiguous, dtype=wp.vec3f) + + # Warp buffers for camera pose outputs + self._pos_w_wp = wp.zeros(self._view.count, dtype=wp.vec3f, device=self._device) + self._quat_w_wp = wp.zeros(self._view.count, dtype=wp.quatf, device=self._device) + + # Intermediate warp buffers for ray results (filled with inf before each raycasting step) + self._ray_distance = wp.zeros((self._view.count, self.num_rays), dtype=wp.float32, device=self._device) + if "normals" in self.cfg.data_types: + self._ray_normal_w = wp.zeros((self._view.count, self.num_rays), dtype=wp.vec3f, device=self._device) + else: + self._ray_normal_w = wp.zeros((1, 1), dtype=wp.vec3f, device=self._device) + + if "distance_to_image_plane" in self.cfg.data_types: + self._distance_to_image_plane_wp = wp.zeros( + (self._view.count, self.num_rays), dtype=wp.float32, device=self._device + ) + + # Torch buffer for ray hits (used by debug visualizer) + self.ray_hits_w = torch.full((self._view.count, self.num_rays, 3), float("inf"), device=self._device) + # Warp view of ray_hits_w + self._ray_hits_w_wp = wp.from_torch(self.ray_hits_w.contiguous(), dtype=wp.vec3f) + + # Cache zero-copy torch views of warp output buffers to avoid per-step wrapper allocation. + self._pos_w_torch = wp.to_torch(self._pos_w_wp) + self._quat_w_torch = wp.to_torch(self._quat_w_wp) + self._ray_distance_torch = wp.to_torch(self._ray_distance) + if "distance_to_image_plane" in self.cfg.data_types: + self._distance_to_image_plane_torch = wp.to_torch(self._distance_to_image_plane_wp) + if "normals" in self.cfg.data_types: + self._ray_normal_w_torch = wp.to_torch(self._ray_normal_w) def _update_buffers_impl(self, env_mask: wp.array): """Fills the buffers of the sensor data.""" + # Convert mask to indices for torch-indexed writes env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) if len(env_ids) == 0: return # increment frame count self._frame[env_ids] += 1 - # compute poses from current view - pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids, clone=True) - pos_w, quat_w = math_utils.combine_frame_transforms( - pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] + # Update world-frame ray starts/directions and camera pose via warp kernel. + # Camera always uses ALIGNMENT_BASE (full orientation) and zero drift. + transforms = self._get_view_transforms_wp() + wp.launch( + update_ray_caster_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + transforms, + env_mask, + self._offset_pos_wp, + self._offset_quat_wp, + self._drift, + self._ray_cast_drift, + self._ray_starts_local, + self._ray_directions_local, + int(ALIGNMENT_BASE), + ], + outputs=[ + self._pos_w_wp, + self._quat_w_wp, + self._ray_starts_w, + self._ray_directions_w, + ], + device=self._device, + ) + + # Write camera pose to CameraData (torch tensors) + self._data.pos_w[env_ids] = self._pos_w_torch[env_ids] + self._data.quat_w_world[env_ids] = self._quat_w_torch[env_ids] + + # Fill ray hit positions with inf before raycasting + wp.launch( + fill_vec3_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._ray_hits_w_wp], + device=self._device, ) - # update the data - self._data.pos_w[env_ids] = pos_w - self._data.quat_w_world[env_ids] = quat_w - # note: full orientation is considered - ray_starts_w = math_utils.quat_apply(quat_w.repeat(1, self.num_rays), self.ray_starts[env_ids]) - ray_starts_w += pos_w.unsqueeze(1) - ray_directions_w = math_utils.quat_apply(quat_w.repeat(1, self.num_rays), self.ray_directions[env_ids]) - - # ray cast and store the hits - # note: we set max distance to 1e6 during the ray-casting. THis is because we clip the distance - # to the image plane and distance to the camera to the maximum distance afterwards in-order to - # match the USD camera behavior. - - # TODO: Make ray-casting work for multiple meshes? - # necessary for regular dictionaries. - self.ray_hits_w, ray_depth, ray_normal, _ = raycast_mesh( - ray_starts_w, - ray_directions_w, - mesh=RayCaster.meshes[self.cfg.mesh_prim_paths[0]], - max_dist=1e6, - return_distance=any( - [name in self.cfg.data_types for name in ["distance_to_image_plane", "distance_to_camera"]] - ), - return_normal="normals" in self.cfg.data_types, + # Fill ray distance with inf before raycasting + wp.launch( + fill_float2d_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._ray_distance], + device=self._device, ) - # update output buffers + + # Determine whether to compute normals + need_normal = int("normals" in self.cfg.data_types) + if need_normal: + # Fill normal buffer with inf before raycasting + wp.launch( + fill_vec3_inf_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float("inf"), self._ray_normal_w], + device=self._device, + ) + + # Ray-cast against the mesh; use a large upper-bound max_dist so depth clipping + # can be applied per-data-type afterwards (matching the original behaviour). + wp.launch( + raycast_mesh_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + RayCaster.meshes[(self.cfg.mesh_prim_paths[0], self._device)].id, + env_mask, + self._ray_starts_w, + self._ray_directions_w, + float(CAMERA_RAYCAST_MAX_DIST), + int(True), # return_distance: always needed for depth output + need_normal, + self._ray_hits_w_wp, + self._ray_distance, + self._ray_normal_w, + ], + device=self._device, + ) + + # Compute distance_to_image_plane using a warp kernel if "distance_to_image_plane" in self.cfg.data_types: - # note: data is in camera frame so we only take the first component (z-axis of camera frame) - distance_to_image_plane = ( - math_utils.quat_apply( - math_utils.quat_inv(quat_w).repeat(1, self.num_rays), - (ray_depth[:, :, None] * ray_directions_w), - ) - )[:, :, 0] - # apply the maximum distance after the transformation - if self.cfg.depth_clipping_behavior == "max": - distance_to_image_plane = torch.clip(distance_to_image_plane, max=self.cfg.max_distance) - distance_to_image_plane[torch.isnan(distance_to_image_plane)] = self.cfg.max_distance - elif self.cfg.depth_clipping_behavior == "zero": - distance_to_image_plane[distance_to_image_plane > self.cfg.max_distance] = 0.0 - distance_to_image_plane[torch.isnan(distance_to_image_plane)] = 0.0 - self._data.output["distance_to_image_plane"][env_ids] = distance_to_image_plane.view( + wp.launch( + compute_distance_to_image_plane_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[ + env_mask, + self._quat_w_wp, + self._ray_distance, + self._ray_directions_w, + ], + outputs=[ + self._distance_to_image_plane_wp, + ], + device=self._device, + ) + # Apply depth clipping on the intermediate buffer (leaves _ray_distance unmodified) + self._apply_depth_clipping(env_mask, self._distance_to_image_plane_wp) + self._data.output["distance_to_image_plane"][env_ids] = self._distance_to_image_plane_torch[env_ids].view( -1, *self.image_shape, 1 ) if "distance_to_camera" in self.cfg.data_types: - if self.cfg.depth_clipping_behavior == "max": - ray_depth = torch.clip(ray_depth, max=self.cfg.max_distance) - elif self.cfg.depth_clipping_behavior == "zero": - ray_depth[ray_depth > self.cfg.max_distance] = 0.0 - self._data.output["distance_to_camera"][env_ids] = ray_depth.view(-1, *self.image_shape, 1) + # d2ip (if requested) was computed before this block so _ray_distance is still unclipped. + self._apply_depth_clipping(env_mask, self._ray_distance) + self._data.output["distance_to_camera"][env_ids] = self._ray_distance_torch[env_ids].view( + -1, *self.image_shape, 1 + ) if "normals" in self.cfg.data_types: - self._data.output["normals"][env_ids] = ray_normal.view(-1, *self.image_shape, 3) + self._data.output["normals"][env_ids] = self._ray_normal_w_torch[env_ids].view(-1, *self.image_shape, 3) def _debug_vis_callback(self, event): # in case it crashes be safe if not hasattr(self, "ray_hits_w"): return - # show ray hit positions - self.ray_visualizer.visualize(self.ray_hits_w.view(-1, 3)) + # filter out missed rays (inf values) before visualizing + ray_hits_flat = self.ray_hits_w.reshape(-1, 3) + valid_mask = ~torch.isinf(ray_hits_flat).any(dim=-1) + viz_points = ray_hits_flat[valid_mask] + # if no valid hits, skip + if viz_points.shape[0] == 0: + return + self.ray_visualizer.visualize(viz_points) """ Private Helpers """ + def _apply_depth_clipping(self, env_mask: wp.array, depth: wp.array) -> None: + """Apply depth clipping in-place on a warp float32 buffer. + + Uses :attr:`cfg.depth_clipping_behavior` to determine the fill value: + ``"max"`` replaces out-of-range and NaN values with :attr:`cfg.max_distance`; + ``"zero"`` replaces them with 0. No-op when behavior is ``"none"``. + + Args: + env_mask: Boolean mask selecting which environments to update. Shape is (num_envs,). + depth: Warp 2-D float32 buffer to clip in-place. Shape is (num_envs, num_rays). + """ + if self.cfg.depth_clipping_behavior == "max": + wp.launch( + apply_depth_clipping_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float(self.cfg.max_distance), float(self.cfg.max_distance), depth], + device=self._device, + ) + elif self.cfg.depth_clipping_behavior == "zero": + wp.launch( + apply_depth_clipping_masked_kernel, + dim=(self._num_envs, self.num_rays), + inputs=[env_mask, float(self.cfg.max_distance), float(0.0), depth], + device=self._device, + ) + elif self.cfg.depth_clipping_behavior == "none": + pass # no clipping: inf values remain as-is + else: + raise ValueError( + f"Unknown depth_clipping_behavior: {self.cfg.depth_clipping_behavior!r}." + " Valid values are 'max', 'zero', and 'none'." + ) + def _check_supported_data_types(self, cfg: RayCasterCameraCfg): """Checks if the data types are supported by the ray-caster camera.""" # check if there is any intersection in unsupported types @@ -362,7 +521,7 @@ def _check_supported_data_types(self, cfg: RayCasterCameraCfg): def _create_buffers(self): """Create buffers for storing data.""" - # prepare drift + # prepare drift (kept as torch tensors so subclasses may use torch indexing) self.drift = torch.zeros(self._view.count, 3, device=self.device) self.ray_cast_drift = torch.zeros(self._view.count, 3, device=self.device) # create the data object diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera_cfg.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera_cfg.py index 98020d845f82..574c95020437 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera_cfg.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera_cfg.py @@ -54,8 +54,8 @@ class OffsetCfg: - ``"max"``: Values are clipped to the maximum value. - ``"zero"``: Values are clipped to zero. - - ``"none``: No clipping is applied. Values will be returned as ``inf`` for ``distance_to_camera`` and ``nan`` - for ``distance_to_image_plane`` data type. + - ``"none"``: No clipping is applied. Values will be returned as ``inf`` for missed rays in both + ``distance_to_camera`` and ``distance_to_image_plane`` data types. """ pattern_cfg: PinholeCameraPatternCfg = MISSING diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py index 7d91e446adac..9c3ef14091c5 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py @@ -69,8 +69,9 @@ class OffsetCfg: The options are: * ``base`` if the rays' starting positions and directions track the full root position and orientation. - * ``yaw`` if the rays' starting positions and directions track root position and only yaw component of - the orientation. This is useful for ray-casting height maps. + * ``yaw`` if the rays' starting positions track root position and the yaw component of the orientation, + while ray directions remain fixed in world frame. This is useful for ray-casting height maps where + the scan footprint should follow the body heading without tilting when the body pitches or rolls. * ``world`` if rays' starting positions and directions are always fixed. This is useful in combination with a mapping package on the robot and querying ray-casts in a global frame. """ diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_data.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_data.py index 6103a2167d66..c317c96f78b2 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_data.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_data.py @@ -3,28 +3,75 @@ # # SPDX-License-Identifier: BSD-3-Clause -from dataclasses import dataclass +from __future__ import annotations -import torch +import warp as wp -@dataclass class RayCasterData: - """Data container for the ray-cast sensor.""" + """Data container for the ray-cast sensor. - pos_w: torch.Tensor = None - """Position of the sensor origin in world frame. - - Shape is (N, 3), where N is the number of sensors. + All public properties return :class:`wp.array` objects backed by device memory. + Use :func:`wp.to_torch` at the call-site when a PyTorch tensor is needed, e.g. + ``wp.to_torch(sensor.data.ray_hits_w)``. """ - quat_w: torch.Tensor = None - """Orientation of the sensor origin in quaternion (x, y, z, w) in world frame. - Shape is (N, 4), where N is the number of sensors. - """ - ray_hits_w: torch.Tensor = None - """The ray hit positions in the world frame. + def __init__(self): + self._pos_w: wp.array | None = None + self._quat_w: wp.array | None = None + self._ray_hits_w: wp.array | None = None - Shape is (N, B, 3), where N is the number of sensors, B is the number of rays - in the scan pattern per sensor. - """ + # Zero-copy torch views; kept alive to prevent GC of the underlying warp buffers. + # Not surfaced as public API — callers should use wp.to_torch() at the call-site. + self._pos_w_torch = None + self._quat_w_torch = None + self._ray_hits_w_torch = None + + @property + def pos_w(self) -> wp.array | None: + """Position of the sensor origin in world frame [m]. + + Shape is (N,), dtype ``wp.vec3f``. In torch this resolves to (N, 3), + where N is the number of sensors. Use :func:`wp.to_torch` to obtain a + :class:`torch.Tensor` view without copying data. + """ + return self._pos_w + + @property + def quat_w(self) -> wp.array | None: + """Orientation of the sensor origin in quaternion (x, y, z, w) in world frame. + + Shape is (N,), dtype ``wp.quatf``. In torch this resolves to (N, 4), + where N is the number of sensors. Use :func:`wp.to_torch` to obtain a + :class:`torch.Tensor` view without copying data. + """ + return self._quat_w + + @property + def ray_hits_w(self) -> wp.array | None: + """The ray hit positions in the world frame [m]. + + Shape is (N, B), dtype ``wp.vec3f``. In torch this resolves to (N, B, 3), + where N is the number of sensors and B is the number of rays per sensor. + Contains ``inf`` for missed hits. Use :func:`wp.to_torch` to obtain a + :class:`torch.Tensor` view without copying data. + """ + return self._ray_hits_w + + def create_buffers(self, num_envs: int, num_rays: int, device: str) -> None: + """Create internal warp buffers and corresponding zero-copy torch views. + + Args: + num_envs: Number of environments / sensors. + num_rays: Number of rays per sensor. + device: Device for tensor storage. + """ + self._device = device + + self._pos_w = wp.zeros(num_envs, dtype=wp.vec3f, device=device) + self._quat_w = wp.zeros(num_envs, dtype=wp.quatf, device=device) + self._ray_hits_w = wp.zeros((num_envs, num_rays), dtype=wp.vec3f, device=device) + + self._pos_w_torch = wp.to_torch(self._pos_w) + self._quat_w_torch = wp.to_torch(self._quat_w) + self._ray_hits_w_torch = wp.to_torch(self._ray_hits_w) diff --git a/source/isaaclab/isaaclab/utils/warp/kernels.py b/source/isaaclab/isaaclab/utils/warp/kernels.py index da2d9123db47..2cc38b7d1996 100644 --- a/source/isaaclab/isaaclab/utils/warp/kernels.py +++ b/source/isaaclab/isaaclab/utils/warp/kernels.py @@ -53,7 +53,7 @@ def raycast_mesh_kernel( this array is not used. max_dist: The maximum ray-cast distance. Defaults to 1e6. return_distance: Whether to return the ray hit distances. Defaults to False. - return_normal: Whether to return the ray hit normals. Defaults to False`. + return_normal: Whether to return the ray hit normals. Defaults to False. return_face_id: Whether to return the ray hit face ids. Defaults to False. """ # get the thread id @@ -79,6 +79,63 @@ def raycast_mesh_kernel( ray_face_id[tid] = f +@wp.kernel(enable_backward=False) +def raycast_mesh_masked_kernel( + # input + mesh: wp.uint64, + env_mask: wp.array(dtype=wp.bool), + ray_starts: wp.array2d(dtype=wp.vec3f), + ray_directions: wp.array2d(dtype=wp.vec3f), + max_dist: wp.float32, + return_distance: int, + return_normal: int, + # output + ray_hits: wp.array2d(dtype=wp.vec3f), + ray_distance: wp.array2d(dtype=wp.float32), + ray_normal: wp.array2d(dtype=wp.vec3f), +): + """Ray-cast against a single static mesh for masked environments. + + Extends :func:`raycast_mesh_kernel` with environment masking and optional distance/normal output, + for use in multi-environment sensor pipelines. + + Launch with ``dim=(num_envs, num_rays)``. + + Args: + mesh: Warp mesh id to ray-cast against. + env_mask: Boolean mask for which environments to update. Shape is (num_envs,). + ray_starts: World-frame ray start positions [m]. Shape is (num_envs, num_rays). + ray_directions: World-frame unit ray directions. Shape is (num_envs, num_rays). + max_dist: Maximum ray-cast distance [m]. + return_distance: Whether to write hit distances to ``ray_distance`` (1) or skip (0). + return_normal: Whether to write surface normals to ``ray_normal`` (1) or skip (0). + ray_hits: Output ray hit positions [m]. Shape is (num_envs, num_rays). + Pre-filled with inf for missed hits; unchanged on miss. + ray_distance: Output hit distances [m]. Shape is (num_envs, num_rays). + Written only when ``return_distance`` is 1; pre-filled with inf for missed hits. + ray_normal: Output surface normals at hit positions. Shape is (num_envs, num_rays). + Written only when ``return_normal`` is 1; pre-filled with inf for missed hits. + """ + env, ray = wp.tid() + if not env_mask[env]: + return + + t = float(0.0) + u = float(0.0) + v = float(0.0) + sign = float(0.0) + n = wp.vec3f() + f = int(0) + + hit = wp.mesh_query_ray(mesh, ray_starts[env, ray], ray_directions[env, ray], max_dist, t, u, v, sign, n, f) + if hit: + ray_hits[env, ray] = ray_starts[env, ray] + t * ray_directions[env, ray] + if return_distance == 1: + ray_distance[env, ray] = t + if return_normal == 1: + ray_normal[env, ray] = n + + @wp.kernel(enable_backward=False) def raycast_static_meshes_kernel( mesh: wp.array2d(dtype=wp.uint64), @@ -110,14 +167,24 @@ def raycast_static_meshes_kernel( account the mesh's position and rotation. This kernel is useful for ray-casting against static meshes that are not expected to move. + .. warning:: + **Known race condition:** When two meshes are equidistant to the same ray, the + ``atomic_min`` + equality-check pattern used for closest-hit resolution is not fully + thread-safe. Two threads may both pass the equality check and write different output + fields (e.g., ``ray_hits`` from mesh A, ``ray_normal`` from mesh B). In practice this + is rare (requires exact floating-point tie) and the position output is still correct, + but normals, face IDs, and mesh IDs may be inconsistent for the affected ray. + See `warp#1058 `_ for progress on a + thread-safe fix. + Args: mesh: The input mesh. The ray-casting is performed against this mesh on the device specified by the `mesh`'s `device` attribute. ray_starts: The input ray start positions. Shape is (B, N, 3). ray_directions: The input ray directions. Shape is (B, N, 3). ray_hits: The output ray hit positions. Shape is (B, N, 3). - ray_distance: The output ray hit distances. Shape is (B, N,), if ``return_distance`` is True. Otherwise, - this array is not used. + ray_distance: The closest hit distance buffer. Shape is (B, N). Updated via ``atomic_min`` for every + thread that records a hit; used to resolve closest-hit among multiple meshes. ray_normal: The output ray hit normals. Shape is (B, N, 3), if ``return_normal`` is True. Otherwise, this array is not used. ray_face_id: The output ray hit face ids. Shape is (B, N,), if ``return_face_id`` is True. Otherwise, @@ -125,7 +192,7 @@ def raycast_static_meshes_kernel( ray_mesh_id: The output ray hit mesh ids. Shape is (B, N,), if ``return_mesh_id`` is True. Otherwise, this array is not used. max_dist: The maximum ray-cast distance. Defaults to 1e6. - return_normal: Whether to return the ray hit normals. Defaults to False`. + return_normal: Whether to return the ray hit normals. Defaults to False. return_face_id: Whether to return the ray hit face ids. Defaults to False. return_mesh_id: Whether to return the mesh id. Defaults to False. """ @@ -141,10 +208,11 @@ def raycast_static_meshes_kernel( # if the ray hit, store the hit data if mesh_query_ray_t.result: wp.atomic_min(ray_distance, tid_env, tid_ray, mesh_query_ray_t.t) - # check if hit distance is less than the current hit distance, only then update the memory - # TODO, in theory we could use the output of atomic_min to avoid the non-thread safe next comparison - # however, warp atomic_min is returning the wrong values on gpu currently. - # FIXME https://github.com/NVIDIA/warp/issues/1058 + # TODO(warp#1058): Use the return value of atomic_min to avoid the non-thread-safe + # equality check below. Currently warp atomic_min returns wrong values on GPU, so we + # fall back to a racy read-back. When two meshes tie on distance, normals/face-ids/ + # mesh-ids may be written by different threads. The hit *position* is still correct + # because all tying threads compute the same world-space point. if mesh_query_ray_t.t == ray_distance[tid_env, tid_ray]: # convert back to world space and update the hit data ray_hits[tid_env, tid_ray] = start_pos + mesh_query_ray_t.t * direction @@ -160,6 +228,7 @@ def raycast_static_meshes_kernel( @wp.kernel(enable_backward=False) def raycast_dynamic_meshes_kernel( + env_mask: wp.array(dtype=wp.bool), mesh: wp.array2d(dtype=wp.uint64), ray_starts: wp.array2d(dtype=wp.vec3), ray_directions: wp.array2d(dtype=wp.vec3), @@ -175,7 +244,7 @@ def raycast_dynamic_meshes_kernel( return_face_id: int = False, return_mesh_id: int = False, ): - """Performs ray-casting against multiple meshes. + """Performs ray-casting against multiple dynamic meshes. This function performs ray-casting against the given meshes using the provided ray start positions and directions. The resulting ray hit positions are stored in the :obj:`ray_hits` array. @@ -183,7 +252,6 @@ def raycast_dynamic_meshes_kernel( The function utilizes the ``mesh_query_ray`` method from the ``wp`` module to perform the actual ray-casting operation. The maximum ray-cast distance is set to ``1e6`` units. - Note: That the ``ray_starts``, ``ray_directions``, and ``ray_hits`` arrays should have compatible shapes and data types to ensure proper execution. Additionally, they all must be in the same frame. @@ -191,29 +259,42 @@ def raycast_dynamic_meshes_kernel( All arguments are expected to be batched with the first dimension (B, batch) being the number of envs and the second dimension (N, num_rays) being the number of rays. For Meshes, W is the number of meshes. + .. warning:: + **Known race condition:** When two meshes are equidistant to the same ray, the + ``atomic_min`` + equality-check pattern used for closest-hit resolution is not fully + thread-safe. Two threads may both pass the equality check and write different output + fields (e.g., ``ray_hits`` from mesh A, ``ray_normal`` from mesh B). In practice this + is rare (requires exact floating-point tie) and the position output is still correct, + but normals, face IDs, and mesh IDs may be inconsistent for the affected ray. + See `warp#1058 `_ for progress on a + thread-safe fix. + Args: + env_mask: Boolean mask selecting which environments to process. Shape is (B,). mesh: The input mesh. The ray-casting is performed against this mesh on the device specified by the `mesh`'s `device` attribute. ray_starts: The input ray start positions. Shape is (B, N, 3). ray_directions: The input ray directions. Shape is (B, N, 3). ray_hits: The output ray hit positions. Shape is (B, N, 3). - ray_distance: The output ray hit distances. Shape is (B, N,), if ``return_distance`` is True. Otherwise, - this array is not used. + ray_distance: The closest hit distance buffer. Shape is (B, N). Updated via ``atomic_min`` for every + thread that records a hit; used to resolve closest-hit among multiple meshes. ray_normal: The output ray hit normals. Shape is (B, N, 3), if ``return_normal`` is True. Otherwise, this array is not used. ray_face_id: The output ray hit face ids. Shape is (B, N,), if ``return_face_id`` is True. Otherwise, this array is not used. ray_mesh_id: The output ray hit mesh ids. Shape is (B, N,), if ``return_mesh_id`` is True. Otherwise, this array is not used. - mesh_positions: The input mesh positions in world frame. Shape is (W, 3). - mesh_rotations: The input mesh rotations in world frame. Shape is (W, 4). + mesh_positions: The input mesh positions in world frame. Shape is (B, W, 3). + mesh_rotations: The input mesh rotations in world frame. Shape is (B, W, 4). max_dist: The maximum ray-cast distance. Defaults to 1e6. - return_normal: Whether to return the ray hit normals. Defaults to False`. + return_normal: Whether to return the ray hit normals. Defaults to False. return_face_id: Whether to return the ray hit face ids. Defaults to False. return_mesh_id: Whether to return the mesh id. Defaults to False. """ # get the thread id tid_mesh_id, tid_env, tid_ray = wp.tid() + if not env_mask[tid_env]: + return mesh_pose = wp.transform(mesh_positions[tid_env, tid_mesh_id], mesh_rotations[tid_env, tid_mesh_id]) mesh_pose_inv = wp.transform_inverse(mesh_pose) @@ -225,10 +306,11 @@ def raycast_dynamic_meshes_kernel( # if the ray hit, store the hit data if mesh_query_ray_t.result: wp.atomic_min(ray_distance, tid_env, tid_ray, mesh_query_ray_t.t) - # check if hit distance is less than the current hit distance, only then update the memory - # TODO, in theory we could use the output of atomic_min to avoid the non-thread safe next comparison - # however, warp atomic_min is returning the wrong values on gpu currently. - # FIXME https://github.com/NVIDIA/warp/issues/1058 + # TODO(warp#1058): Use the return value of atomic_min to avoid the non-thread-safe + # equality check below. Currently warp atomic_min returns wrong values on GPU, so we + # fall back to a racy read-back. When two meshes tie on distance, normals/face-ids/ + # mesh-ids may be written by different threads. The hit *position* is still correct + # because all tying threads compute the same world-space point. if mesh_query_ray_t.t == ray_distance[tid_env, tid_ray]: # convert back to world space and update the hit data hit_pos = start_pos + mesh_query_ray_t.t * direction diff --git a/source/isaaclab/isaaclab/utils/warp/ops.py b/source/isaaclab/isaaclab/utils/warp/ops.py index 313a7fd43afb..a3ee273b627c 100644 --- a/source/isaaclab/isaaclab/utils/warp/ops.py +++ b/source/isaaclab/isaaclab/utils/warp/ops.py @@ -19,6 +19,10 @@ from . import kernels +# Cache of all-True env masks keyed by (n_envs, device) to avoid per-call allocations in +# raycast_dynamic_meshes. Populated lazily on first call with a given (n_envs, device) pair. +_all_env_mask_cache: dict[tuple[int, str], wp.array] = {} + def raycast_mesh( ray_starts: torch.Tensor, @@ -335,11 +339,19 @@ def raycast_dynamic_meshes( mesh_orientations_w = mesh_orientations_w.to(dtype=torch.float32, device=torch_device).contiguous() mesh_quat_wp_w = wp.from_torch(mesh_orientations_w, dtype=wp.quat) + # All environments active when called through this public API. + # Cache the mask by (n_envs, device) to avoid a per-call allocation. + cache_key = (n_envs, str(torch_device)) + if cache_key not in _all_env_mask_cache: + _all_env_mask_cache[cache_key] = wp.from_torch(torch.ones(n_envs, dtype=torch.bool, device=torch_device)) + all_env_mask = _all_env_mask_cache[cache_key] + # launch the warp kernel wp.launch( kernel=kernels.raycast_dynamic_meshes_kernel, dim=[n_meshes, n_envs, n_rays_per_env], inputs=[ + all_env_mask, mesh_ids_wp, ray_starts_wp, ray_directions_wp, diff --git a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py index 2b079760e16a..8657c938c691 100644 --- a/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_multi_mesh_ray_caster_camera.py @@ -777,3 +777,35 @@ def test_output_equal_to_usd_camera_when_intrinsics_set(setup_simulation): ) del camera_usd, camera_warp + + +@pytest.mark.isaacsim_ci +def test_image_mesh_ids_identifies_hit_mesh(setup_simulation): + """image_mesh_ids must contain 0 for ground-plane hits (only one mesh registered).""" + sim, dt, camera_cfg = setup_simulation + + cfg = copy.deepcopy(camera_cfg) + cfg.update_mesh_ids = True + cfg.data_types = ["distance_to_camera"] + + camera = MultiMeshRayCasterCamera(cfg=cfg) + sim.reset() + camera.update(dt) + + mesh_ids = camera.data.image_mesh_ids # shape (N, H, W, 1), dtype torch.int16 + assert mesh_ids is not None, "image_mesh_ids should not be None when update_mesh_ids=True" + assert mesh_ids.shape[-1] == 1 + assert mesh_ids.dtype == torch.int16 + + # Identify actual hits via distance < inf. This relies on depth_clipping_behavior="none" + # (the default), which leaves missed rays at the Warp-kernel fill value of inf. + # Under "max" clipping, missed rays would be clamped to a finite max_distance, making + # the inf comparison incorrect. + hit_mask = camera.data.output["distance_to_camera"][0, :, :, 0] < float("inf") + assert hit_mask.any(), "Expected at least some rays to hit the ground plane" + + # All hits against the single registered mesh must carry mesh_id=0 (first mesh index). + hit_mesh_ids = mesh_ids[0, :, :, 0][hit_mask] + assert torch.all(hit_mesh_ids == 0), ( + f"All hits against the single ground mesh must have mesh_id=0, got: {hit_mesh_ids.unique()}" + ) diff --git a/source/isaaclab/test/sensors/test_ray_caster.py b/source/isaaclab/test/sensors/test_ray_caster.py index 944287c549b4..5dffb5ccd015 100644 --- a/source/isaaclab/test/sensors/test_ray_caster.py +++ b/source/isaaclab/test/sensors/test_ray_caster.py @@ -18,7 +18,9 @@ # Import after app launch import warp as wp -from isaaclab.utils.math import matrix_from_quat, quat_from_euler_xyz, random_orientation +from isaaclab.sensors.ray_caster.kernels import quat_yaw_only as _quat_yaw_only_func +from isaaclab.utils.math import matrix_from_quat, quat_from_euler_xyz, random_orientation, yaw_quat +from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel as _raycast_mesh_masked_kernel from isaaclab.utils.warp.ops import convert_to_warp_mesh, raycast_dynamic_meshes, raycast_mesh @@ -239,3 +241,205 @@ def test_raycast_random_cube(raycast_setup): torch.testing.assert_close(ray_distance, ray_distance_m) torch.testing.assert_close(ray_normal, ray_normal_m) torch.testing.assert_close(ray_face_id, ray_face_id_m) + + +# --------------------------------------------------------------------------- +# Tests for raycast_mesh_masked_kernel (new kernel in utils/warp/kernels.py) +# --------------------------------------------------------------------------- + +_SENTINEL = -2.0 # value pre-filled into output buffers; chosen outside [-1, 1] so it cannot +# equal any component of a unit-length surface normal, making "not written" assertions unambiguous. + + +def _make_masked_buffers(device, n_envs, n_rays): + """Allocate all warp buffers needed by raycast_mesh_masked_kernel. + + ray_dist_w and ray_normal_w are pre-filled with _SENTINEL so that tests can + meaningfully assert those buffers were *not* written when the corresponding + return flag is 0. + """ + ray_starts_w = wp.zeros((n_envs, n_rays), dtype=wp.vec3f, device=device) + ray_dirs_w = wp.zeros((n_envs, n_rays), dtype=wp.vec3f, device=device) + ray_hits_w = wp.zeros((n_envs, n_rays), dtype=wp.vec3f, device=device) + ray_dist_w = wp.zeros((n_envs, n_rays), dtype=wp.float32, device=device) + wp.to_torch(ray_dist_w).fill_(_SENTINEL) + ray_normal_w = wp.zeros((n_envs, n_rays), dtype=wp.vec3f, device=device) + wp.to_torch(ray_normal_w).fill_(_SENTINEL) + return ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w + + +def test_raycast_mesh_masked_kernel_hits_only(raycast_setup): + """return_distance=0, return_normal=0: only ray_hits are written on a hit.""" + device = raycast_setup["device"] + mesh_id = raycast_setup["single_mesh_id"] + expected_hits = raycast_setup["expected_ray_hits"] # shape (1, 2, 3) + + n_envs, n_rays = 1, 2 + ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w = _make_masked_buffers(device, n_envs, n_rays) + env_mask = wp.array([True], dtype=wp.bool, device=device) + + wp.to_torch(ray_starts_w)[:] = torch.tensor([[[0, -0.35, -5], [0.25, 0.35, -5]]], device=device) + wp.to_torch(ray_dirs_w)[:] = torch.tensor([[[0, 0, 1], [0, 0, 1]]], device=device) + wp.to_torch(ray_hits_w).fill_(float("inf")) + + wp.launch( + _raycast_mesh_masked_kernel, + dim=(n_envs, n_rays), + inputs=[mesh_id, env_mask, ray_starts_w, ray_dirs_w, float(1e6), 0, 0, ray_hits_w, ray_dist_w, ray_normal_w], + device=device, + ) + + torch.testing.assert_close(wp.to_torch(ray_hits_w), expected_hits) + assert torch.all(wp.to_torch(ray_dist_w) == _SENTINEL), "Distance buffer must not be written when return_distance=0" + assert torch.all(wp.to_torch(ray_normal_w) == _SENTINEL), "Normal buffer must not be written when return_normal=0" + + +def test_raycast_mesh_masked_kernel_with_distance(raycast_setup): + """return_distance=1: distances are written in addition to hits.""" + device = raycast_setup["device"] + mesh_id = raycast_setup["single_mesh_id"] + + n_envs, n_rays = 1, 2 + ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w = _make_masked_buffers(device, n_envs, n_rays) + env_mask = wp.array([True], dtype=wp.bool, device=device) + + wp.to_torch(ray_starts_w)[:] = torch.tensor([[[0, -0.35, -5], [0.25, 0.35, -5]]], device=device) + wp.to_torch(ray_dirs_w)[:] = torch.tensor([[[0, 0, 1], [0, 0, 1]]], device=device) + wp.to_torch(ray_hits_w).fill_(float("inf")) + + wp.launch( + _raycast_mesh_masked_kernel, + dim=(n_envs, n_rays), + inputs=[mesh_id, env_mask, ray_starts_w, ray_dirs_w, float(1e6), 1, 0, ray_hits_w, ray_dist_w, ray_normal_w], + device=device, + ) + + # Cube bottom at z=-0.5, rays start at z=-5 going +z, distance = 4.5 + torch.testing.assert_close(wp.to_torch(ray_dist_w), torch.tensor([[4.5, 4.5]], device=device)) + assert torch.all(wp.to_torch(ray_normal_w) == _SENTINEL), "Normal buffer must not be written when return_normal=0" + + +def test_raycast_mesh_masked_kernel_with_normal(raycast_setup): + """return_distance=1, return_normal=1: both distances and surface normals are written.""" + device = raycast_setup["device"] + mesh_id = raycast_setup["single_mesh_id"] + + n_envs, n_rays = 1, 2 + ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w = _make_masked_buffers(device, n_envs, n_rays) + env_mask = wp.array([True], dtype=wp.bool, device=device) + + wp.to_torch(ray_starts_w)[:] = torch.tensor([[[0, -0.35, -5], [0.25, 0.35, -5]]], device=device) + wp.to_torch(ray_dirs_w)[:] = torch.tensor([[[0, 0, 1], [0, 0, 1]]], device=device) + wp.to_torch(ray_hits_w).fill_(float("inf")) + + wp.launch( + _raycast_mesh_masked_kernel, + dim=(n_envs, n_rays), + inputs=[mesh_id, env_mask, ray_starts_w, ray_dirs_w, float(1e6), 1, 1, ray_hits_w, ray_dist_w, ray_normal_w], + device=device, + ) + + # Cube bottom at z=-0.5, rays start at z=-5, distance = 4.5 + torch.testing.assert_close(wp.to_torch(ray_dist_w), torch.tensor([[4.5, 4.5]], device=device)) + torch.testing.assert_close( + wp.to_torch(ray_normal_w), + torch.tensor([[[0, 0, -1], [0, 0, -1]]], device=device, dtype=torch.float32), + ) + + +def test_raycast_mesh_masked_kernel_env_mask(raycast_setup): + """Masked-out environments must not be written.""" + device = raycast_setup["device"] + mesh_id = raycast_setup["single_mesh_id"] + + n_envs, n_rays = 2, 2 + ray_starts_w, ray_dirs_w, ray_hits_w, ray_dist_w, ray_normal_w = _make_masked_buffers(device, n_envs, n_rays) + env_mask = wp.array([True, False], dtype=wp.bool, device=device) + + starts = torch.tensor([[[0, -0.35, -5], [0.25, 0.35, -5]], [[0, -0.35, -5], [0.25, 0.35, -5]]], device=device) + dirs = torch.tensor([[[0, 0, 1], [0, 0, 1]], [[0, 0, 1], [0, 0, 1]]], device=device) + wp.to_torch(ray_starts_w)[:] = starts + wp.to_torch(ray_dirs_w)[:] = dirs + wp.to_torch(ray_hits_w).fill_(float("inf")) + + wp.launch( + _raycast_mesh_masked_kernel, + dim=(n_envs, n_rays), + inputs=[mesh_id, env_mask, ray_starts_w, ray_dirs_w, float(1e6), 1, 0, ray_hits_w, ray_dist_w, ray_normal_w], + device=device, + ) + + hits = wp.to_torch(ray_hits_w) + dist = wp.to_torch(ray_dist_w) + + assert not torch.isinf(hits[0]).any(), "Active env 0 should have valid hits" + torch.testing.assert_close(dist[0], torch.tensor([4.5, 4.5], device=device)) + assert torch.isinf(hits[1]).all(), "Masked env 1 hits must remain inf" + assert torch.all(dist[1] == _SENTINEL), "Masked env 1 distances must remain at sentinel" + assert torch.all(wp.to_torch(ray_normal_w) == _SENTINEL), "Normal buffer must not be written when return_normal=0" + + +# --------------------------------------------------------------------------- +# Test quat_yaw_only correctness (regression for atan2-based fix) +# --------------------------------------------------------------------------- + + +@wp.kernel(enable_backward=False) +def _call_quat_yaw_only(q_in: wp.array(dtype=wp.quatf), q_out: wp.array(dtype=wp.quatf)): + i = wp.tid() + q_out[i] = _quat_yaw_only_func(q_in[i]) + + +def test_quat_yaw_only_pure_yaw(): + """Pure yaw: quat_yaw_only should match the yaw_quat() reference for all yaw angles.""" + device = "cuda" if torch.cuda.is_available() else "cpu" + yaw_angles = torch.tensor([0.0, 0.5, 1.2, -0.8, np.pi], device=device) + + for yaw in yaw_angles: + q_torch = quat_from_euler_xyz( + torch.tensor([0.0], device=device), + torch.tensor([0.0], device=device), + yaw.unsqueeze(0), + ) # shape (1, 4), xyzw + + expected = yaw_quat(q_torch) # shape (1, 4) + + q_in = wp.from_torch(q_torch.contiguous(), dtype=wp.quatf) + q_out = wp.zeros(1, dtype=wp.quatf, device=device) + wp.launch(_call_quat_yaw_only, dim=1, inputs=[q_in, q_out], device=device) + result = wp.to_torch(q_out) # shape (1, 4) + + torch.testing.assert_close(result, expected, atol=1e-5, rtol=1e-5) + + +def test_quat_yaw_only_with_pitch_roll(): + """Non-zero pitch and roll: only the yaw component should be preserved. + + This is the regression test for the old bug where simply zeroing qx/qy and + renormalizing gave the wrong answer when pitch or roll was non-zero. + """ + device = "cuda" if torch.cuda.is_available() else "cpu" + + # Several combined pitch+roll+yaw orientations: (roll, pitch, yaw) + test_cases = [ + (0.3, 0.4, 1.2), + (0.5, 0.0, 0.7), + (-0.2, 0.6, -1.0), + (1.0, 1.0, 0.0), # heavy pitch+roll, zero yaw → result should be identity + ] + + for roll, pitch, yaw in test_cases: + q_torch = quat_from_euler_xyz( + torch.tensor([roll], device=device), + torch.tensor([pitch], device=device), + torch.tensor([yaw], device=device), + ) # shape (1, 4), xyzw + + expected = yaw_quat(q_torch) # shape (1, 4) + + q_in = wp.from_torch(q_torch.contiguous(), dtype=wp.quatf) + q_out = wp.zeros(1, dtype=wp.quatf, device=device) + wp.launch(_call_quat_yaw_only, dim=1, inputs=[q_in, q_out], device=device) + result = wp.to_torch(q_out) + + torch.testing.assert_close(result, expected, atol=1e-5, rtol=1e-5) diff --git a/source/isaaclab/test/sensors/test_ray_caster_camera.py b/source/isaaclab/test/sensors/test_ray_caster_camera.py index c81ac9b2d74e..cc10b092a806 100644 --- a/source/isaaclab/test/sensors/test_ray_caster_camera.py +++ b/source/isaaclab/test/sensors/test_ray_caster_camera.py @@ -962,3 +962,142 @@ def test_sensor_print(setup_sim): sim.reset() # print info print(sensor) + + +@pytest.mark.isaacsim_ci +def test_depth_clipping_d2ip_and_d2c_are_independent(setup_sim): + """Clipping distance_to_image_plane must not corrupt distance_to_camera and vice versa. + + Both are derived from the same raw ray_distance buffer. If that buffer is modified + in-place by one clipping pass it would corrupt the other. This test verifies that + requesting both data types simultaneously gives results consistent with requesting + each one alone. + """ + sim, camera_cfg, dt = setup_sim + + base_cfg = RayCasterCameraCfg( + prim_path="/World/Camera", + mesh_prim_paths=["/World/defaultGroundPlane"], + offset=RayCasterCameraCfg.OffsetCfg(pos=(2.5, 2.5, 6.0), rot=(0.0, 0.1305, 0.0, 0.9914449), convention="world"), + pattern_cfg=patterns.PinholeCameraPatternCfg.from_intrinsic_matrix( + focal_length=38.0, + intrinsic_matrix=[380.08, 0.0, 467.79, 0.0, 380.08, 262.05, 0.0, 0.0, 1.0], + height=540, + width=960, + ), + max_distance=5.0, + data_types=["distance_to_image_plane", "distance_to_camera"], + depth_clipping_behavior="max", + update_period=0, + ) + + # Camera requesting both data types simultaneously + sim_utils.create_prim("/World/CameraJoint", "Xform") + cfg_joint = copy.deepcopy(base_cfg) + cfg_joint.prim_path = "/World/CameraJoint" + cam_joint = RayCasterCamera(cfg_joint) + + # Camera requesting only d2ip + sim_utils.create_prim("/World/CameraD2IP", "Xform") + cfg_d2ip = copy.deepcopy(base_cfg) + cfg_d2ip.prim_path = "/World/CameraD2IP" + cfg_d2ip.data_types = ["distance_to_image_plane"] + cam_d2ip = RayCasterCamera(cfg_d2ip) + + # Camera requesting only d2c + sim_utils.create_prim("/World/CameraD2C", "Xform") + cfg_d2c = copy.deepcopy(base_cfg) + cfg_d2c.prim_path = "/World/CameraD2C" + cfg_d2c.data_types = ["distance_to_camera"] + cam_d2c = RayCasterCamera(cfg_d2c) + + sim.reset() + + cam_joint.update(dt) + cam_d2ip.update(dt) + cam_d2c.update(dt) + + d2ip_joint = cam_joint.data.output["distance_to_image_plane"] + d2c_joint = cam_joint.data.output["distance_to_camera"] + d2ip_solo = cam_d2ip.data.output["distance_to_image_plane"] + d2c_solo = cam_d2c.data.output["distance_to_camera"] + + # Joint camera must match solo cameras (clipping one must not affect the other) + torch.testing.assert_close(d2ip_joint, d2ip_solo, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(d2c_joint, d2c_solo, atol=1e-5, rtol=1e-5) + + # Both should be clipped to max_distance (camera is 6 m above ground, max_distance=5 m) + assert d2ip_joint.max().item() <= base_cfg.max_distance + 1e-4 + assert d2c_joint.max().item() <= base_cfg.max_distance + 1e-4 + + +@pytest.mark.isaacsim_ci +def test_frame_counter_increments_per_update(setup_sim): + """frame counter must increment by exactly 1 per update() call and reset to 0 on reset().""" + sim, camera_cfg, dt = setup_sim + camera = RayCasterCamera(cfg=camera_cfg) + sim.reset() + + assert torch.all(camera.frame == 0), "Frame must start at 0" + + n_steps = 7 + for step in range(1, n_steps + 1): + sim.step() + camera.update(dt, force_recompute=True) + assert camera.frame[0].item() == step, f"Frame must be {step} after {step} update(s)" + + # Partial reset: only env 0 (single-env camera, but API accepts env_ids) + camera.reset(env_ids=[0]) + assert camera.frame[0].item() == 0, "Frame must be 0 after reset(env_ids=[0])" + + # Full reset + for _ in range(3): + sim.step() + camera.update(dt, force_recompute=True) + camera.reset() + assert torch.all(camera.frame == 0), "Frame must be 0 after full reset()" + + +@pytest.mark.isaacsim_ci +def test_set_intrinsic_matrices_updates_output(setup_sim): + """Depth output must change when intrinsics are updated via set_intrinsic_matrices(). + + This tests that the warp view refresh in set_intrinsic_matrices() actually takes + effect: stale warp views would cause subsequent images to use the old ray pattern. + """ + sim, camera_cfg, dt = setup_sim + + # Place camera looking straight down at the ground + camera_cfg = copy.deepcopy(camera_cfg) + camera_cfg.offset = RayCasterCameraCfg.OffsetCfg(pos=(0.0, 0.0, 5.0), rot=(0.0, 0.0, 0.0, 1.0), convention="world") + camera_cfg.data_types = ["distance_to_camera"] + camera = RayCasterCamera(cfg=camera_cfg) + sim.reset() + + # Capture output with default focal length (24 mm → 20.955 mm aperture) + for _ in range(3): + sim.step() + camera.update(dt) + output_before = camera.data.output["distance_to_camera"].clone() + + # Change to a very different focal length (longer → tighter FOV → depth values differ at edges) + new_matrix = torch.tensor( + [[200.0, 0.0, 320.0], [0.0, 200.0, 240.0], [0.0, 0.0, 1.0]], + device=camera.device, + ).unsqueeze(0) + camera.set_intrinsic_matrices(new_matrix, focal_length=1.0) + + for _ in range(3): + sim.step() + camera.update(dt) + output_after = camera.data.output["distance_to_camera"].clone() + + # Outputs must differ after intrinsics change (different ray angles → different depths) + assert not torch.allclose(output_before, output_after, atol=1e-3), ( + "Depth output must change when intrinsic matrix is updated; unchanged output indicates stale warp ray buffers." + ) + # With depth_clipping_behavior="none" (default), missed rays produce inf — that is valid. + # No NaN values must appear; where rays hit, depth must be positive. + assert not torch.any(torch.isnan(output_after)), "Expected no NaN values in depth output after intrinsics update" + if torch.any(torch.isfinite(output_after)): + assert output_after[torch.isfinite(output_after)].min() > 0 diff --git a/source/isaaclab/test/sensors/test_ray_caster_integration.py b/source/isaaclab/test/sensors/test_ray_caster_integration.py new file mode 100644 index 000000000000..62b10a679661 --- /dev/null +++ b/source/isaaclab/test/sensors/test_ray_caster_integration.py @@ -0,0 +1,439 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# pyright: reportPrivateUsage=none + +"""Integration tests for ray caster sensor view paths, env_mask, and intrinsics. + +These tests require Isaac Sim (AppLauncher). They cover the integration-level +items from ``TODO_ray_caster_kernel_tests.md``: + +- ``_get_view_transforms_wp`` ArticulationView and RigidBodyView paths +- ``MultiMeshRayCaster`` env_mask behavior +- ``MultiMeshRayCasterCamera.set_intrinsic_matrices`` propagation +- ``_update_mesh_transforms`` non-identity orientation offset (known bug, xfail) +- Depth clipping ordering for ``MultiMeshRayCasterCamera`` +""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True, enable_cameras=True).app + +import copy + +import numpy as np +import pytest +import torch +import warp as wp + +from pxr import UsdGeom, UsdPhysics + +import isaaclab.sim as sim_utils +from isaaclab.sensors.ray_caster import ( + MultiMeshRayCaster, + MultiMeshRayCasterCamera, + MultiMeshRayCasterCameraCfg, + MultiMeshRayCasterCfg, + RayCaster, + RayCasterCfg, + patterns, +) +from isaaclab.terrains.trimesh.utils import make_plane +from isaaclab.terrains.utils import create_prim_from_mesh + +_GROUND_PATH = "/World/Ground" +_DT = 0.01 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_sim_and_ground(): + """Create a blank stage with a flat ground plane at z=0.""" + sim_utils.create_new_stage() + sim = sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=_DT)) + mesh = make_plane(size=(100, 100), height=0.0, center_zero=True) + create_prim_from_mesh(_GROUND_PATH, mesh) + sim_utils.update_stage() + return sim + + +def _single_downward_ray_cfg(prim_path: str) -> RayCasterCfg: + """RayCasterCfg with a single downward ray, no offset, world alignment.""" + return RayCasterCfg( + prim_path=prim_path, + mesh_prim_paths=[_GROUND_PATH], + update_period=0, + offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, 0.0, 0.0, 1.0)), + debug_vis=False, + pattern_cfg=patterns.GridPatternCfg(resolution=1.0, size=(0.0, 0.0), direction=(0.0, 0.0, -1.0)), + ray_alignment="world", + ) + + +@pytest.fixture +def sim_ground(): + sim = _make_sim_and_ground() + yield sim + sim.stop() + sim.clear_instance() + + +# --------------------------------------------------------------------------- +# _get_view_transforms_wp: ArticulationView path +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_articulation_view_path(sim_ground): + """Mount a ray caster on a prim with ArticulationRootAPI. + + Verifies that sensor pos_w matches the prim's initial position and that + the downward ray hits the ground plane. This exercises the + ``ArticulationView.get_root_transforms()`` quaternion-convention path in + :meth:`_get_view_transforms_wp`. + """ + sim = sim_ground + expected_pos = (3.0, 4.0, 5.0) + + prim_path = "/World/ArticulatedBody" + sim_utils.create_prim(prim_path, "Xform", translation=expected_pos) + stage = sim_utils.get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + UsdPhysics.RigidBodyAPI.Apply(prim) + UsdPhysics.ArticulationRootAPI.Apply(prim) + # Mass is needed for physics; collision is needed for PhysX to track the body. + mass_api = UsdPhysics.MassAPI.Apply(prim) + mass_api.CreateMassAttr().Set(1.0) + # Create a small collision cube so PhysX treats this as a real body. + cube_path = f"{prim_path}/CollisionCube" + cube_geom = UsdGeom.Cube.Define(stage, cube_path) + cube_geom.CreateSizeAttr().Set(0.1) + UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path)) + sim_utils.update_stage() + + sensor = RayCaster(_single_downward_ray_cfg(prim_path)) + sim.reset() + sensor.update(_DT) + + pos_w = wp.to_torch(sensor.data.pos_w)[0].cpu().numpy() + np.testing.assert_allclose( + pos_w, + expected_pos, + atol=0.15, + err_msg="ArticulationView: sensor pos_w must match initial prim position", + ) + + hits = wp.to_torch(sensor.data.ray_hits_w)[0, 0].cpu().numpy() + assert abs(hits[2]) < 0.5, f"ArticulationView: downward ray should hit near z=0, got z={hits[2]}" + + +# --------------------------------------------------------------------------- +# _get_view_transforms_wp: RigidBodyView path +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_rigid_body_view_path(sim_ground): + """Mount a ray caster on a prim with RigidBodyAPI (no ArticulationRootAPI). + + Exercises the ``RigidBodyView.get_transforms()`` path in + :meth:`_get_view_transforms_wp`. + """ + sim = sim_ground + expected_pos = (1.0, 2.0, 6.0) + + prim_path = "/World/RigidBody" + sim_utils.create_prim(prim_path, "Xform", translation=expected_pos) + stage = sim_utils.get_current_stage() + prim = stage.GetPrimAtPath(prim_path) + UsdPhysics.RigidBodyAPI.Apply(prim) + mass_api = UsdPhysics.MassAPI.Apply(prim) + mass_api.CreateMassAttr().Set(1.0) + cube_path = f"{prim_path}/CollisionCube" + cube_geom = UsdGeom.Cube.Define(stage, cube_path) + cube_geom.CreateSizeAttr().Set(0.1) + UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path)) + sim_utils.update_stage() + + sensor = RayCaster(_single_downward_ray_cfg(prim_path)) + sim.reset() + sensor.update(_DT) + + pos_w = wp.to_torch(sensor.data.pos_w)[0].cpu().numpy() + np.testing.assert_allclose( + pos_w, + expected_pos, + atol=0.15, + err_msg="RigidBodyView: sensor pos_w must match initial prim position", + ) + + hits = wp.to_torch(sensor.data.ray_hits_w)[0, 0].cpu().numpy() + assert abs(hits[2]) < 0.5, f"RigidBodyView: downward ray should hit near z=0, got z={hits[2]}" + + +# --------------------------------------------------------------------------- +# MultiMeshRayCasterCamera.set_intrinsic_matrices +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sim_ground_camera(): + """Fixture providing sim + a base MultiMeshRayCasterCameraCfg.""" + sim = _make_sim_and_ground() + + camera_cfg = MultiMeshRayCasterCameraCfg( + prim_path="/World/Camera", + mesh_prim_paths=[_GROUND_PATH], + update_period=0, + offset=MultiMeshRayCasterCameraCfg.OffsetCfg(pos=(0.0, 0.0, 5.0), rot=(0.0, 0.0, 0.0, 1.0), convention="world"), + debug_vis=False, + pattern_cfg=patterns.PinholeCameraPatternCfg( + focal_length=24.0, + horizontal_aperture=20.955, + height=480, + width=640, + ), + data_types=["distance_to_camera"], + ) + + sim_utils.create_prim("/World/Camera", "Xform") + + yield sim, camera_cfg + + sim.stop() + sim.clear_instance() + + +@pytest.mark.isaacsim_ci +def test_multi_mesh_camera_set_intrinsic_matrices(sim_ground_camera): + """Depth output must change when intrinsics are updated on MultiMeshRayCasterCamera. + + The multi-mesh variant overrides ``_initialize_rays_impl`` without calling + ``super()``, so the warp view refresh path may differ from RayCasterCamera. + This test verifies that ``set_intrinsic_matrices`` actually takes effect. + """ + sim, camera_cfg = sim_ground_camera + + camera = MultiMeshRayCasterCamera(cfg=camera_cfg) + sim.reset() + + # Capture output with default intrinsics + for _ in range(3): + sim.step() + camera.update(_DT) + output_before = camera.data.output["distance_to_camera"].clone() + + # Change to a very different intrinsic matrix (different FOV) + new_matrix = torch.tensor( + [[200.0, 0.0, 320.0], [0.0, 200.0, 240.0], [0.0, 0.0, 1.0]], + device=camera.device, + ).unsqueeze(0) + camera.set_intrinsic_matrices(new_matrix, focal_length=1.0) + + for _ in range(3): + sim.step() + camera.update(_DT) + output_after = camera.data.output["distance_to_camera"].clone() + + assert not torch.allclose(output_before, output_after, atol=1e-3), ( + "MultiMeshRayCasterCamera: depth output must change after set_intrinsic_matrices; " + "unchanged output indicates stale warp ray buffers." + ) + assert not torch.any(torch.isnan(output_after)), "No NaN values expected after intrinsics update" + + +# --------------------------------------------------------------------------- +# Depth clipping ordering for MultiMeshRayCasterCamera +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_multi_mesh_camera_d2ip_and_d2c_independent(sim_ground_camera): + """Requesting both d2ip and d2c simultaneously must produce correct independent results. + + The ``distance_to_image_plane`` computation reads ``_ray_distance`` before + ``distance_to_camera`` clips it in-place. This test verifies the two data + types do not interfere with each other. + """ + sim, base_cfg = sim_ground_camera + + joint_cfg = copy.deepcopy(base_cfg) + joint_cfg.prim_path = "/World/CameraJoint" + joint_cfg.data_types = ["distance_to_image_plane", "distance_to_camera"] + joint_cfg.max_distance = 4.5 # camera is 5 m up, so some rays should be clipped + joint_cfg.depth_clipping_behavior = "max" + sim_utils.create_prim("/World/CameraJoint", "Xform") + cam_joint = MultiMeshRayCasterCamera(joint_cfg) + + d2ip_cfg = copy.deepcopy(base_cfg) + d2ip_cfg.prim_path = "/World/CameraD2IP" + d2ip_cfg.data_types = ["distance_to_image_plane"] + d2ip_cfg.max_distance = 4.5 + d2ip_cfg.depth_clipping_behavior = "max" + sim_utils.create_prim("/World/CameraD2IP", "Xform") + cam_d2ip = MultiMeshRayCasterCamera(d2ip_cfg) + + d2c_cfg = copy.deepcopy(base_cfg) + d2c_cfg.prim_path = "/World/CameraD2C" + d2c_cfg.data_types = ["distance_to_camera"] + d2c_cfg.max_distance = 4.5 + d2c_cfg.depth_clipping_behavior = "max" + sim_utils.create_prim("/World/CameraD2C", "Xform") + cam_d2c = MultiMeshRayCasterCamera(d2c_cfg) + + sim.reset() + + cam_joint.update(_DT) + cam_d2ip.update(_DT) + cam_d2c.update(_DT) + + d2ip_joint = cam_joint.data.output["distance_to_image_plane"] + d2c_joint = cam_joint.data.output["distance_to_camera"] + d2ip_solo = cam_d2ip.data.output["distance_to_image_plane"] + d2c_solo = cam_d2c.data.output["distance_to_camera"] + + # Joint camera must match solo cameras (clipping one must not corrupt the other) + torch.testing.assert_close(d2ip_joint, d2ip_solo, atol=1e-5, rtol=1e-5) + torch.testing.assert_close(d2c_joint, d2c_solo, atol=1e-5, rtol=1e-5) + + +# --------------------------------------------------------------------------- +# MultiMeshRayCaster env_mask behavior +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_multi_mesh_env_mask_preserves_masked_buffers(sim_ground): + """Masked environments must retain their pre-update buffer values. + + Creates a single-env MultiMeshRayCaster, captures output after one update, + then calls ``_update_buffers_impl`` with the environment masked out and + verifies the output buffers are unchanged. + """ + sim = sim_ground + + prim_path = "/World/Sensor" + sim_utils.create_prim(prim_path, "Xform", translation=(0.0, 0.0, 3.0)) + + cfg = MultiMeshRayCasterCfg( + prim_path=prim_path, + mesh_prim_paths=[_GROUND_PATH], + update_period=0, + offset=MultiMeshRayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, 0.0, 0.0, 1.0)), + debug_vis=False, + pattern_cfg=patterns.GridPatternCfg(resolution=1.0, size=(0.0, 0.0), direction=(0.0, 0.0, -1.0)), + ray_alignment="world", + ) + sensor = MultiMeshRayCaster(cfg) + sim.reset() + + # First update: populate buffers with real values + sensor.update(_DT) + hits_before = wp.to_torch(sensor.data.ray_hits_w).clone() + + # Second update with env masked out: buffers must not change + mask_all_false = wp.array([False], dtype=wp.bool, device=sensor.device) + sensor._update_buffers_impl(mask_all_false) + + hits_after = wp.to_torch(sensor.data.ray_hits_w) + torch.testing.assert_close( + hits_after, + hits_before, + atol=0.0, + rtol=0.0, + msg="Masked env: ray_hits_w must be unchanged after update with env masked out", + ) + + +# --------------------------------------------------------------------------- +# _update_mesh_transforms: non-identity orientation offset +# --------------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_update_mesh_transforms_non_identity_offset(sim_ground): + """Tracked mesh position must account for body orientation when applying offset. + + Setup: a kinematic rigid body at (0, 0, 2) rotated 90 deg around Z, with a + child mesh offset by (1, 0, 0) in the body's local frame. + + Correct world position of mesh = body_pos + rotate(body_ori, local_offset) + = (0, 0, 2) + rotate(90degZ, (1, 0, 0)) + = (0, 0, 2) + (0, 1, 0) + = (0, 1, 2) + + Naive subtraction (the old bug) would give: body_pos - offset = (-1, 0, 2). + """ + sim = sim_ground + + from isaaclab.utils.math import quat_from_euler_xyz + + # 90 deg yaw quaternion in xyzw + yaw90 = quat_from_euler_xyz(torch.tensor([0.0]), torch.tensor([0.0]), torch.tensor([torch.pi / 2])) + yaw90_xyzw = tuple(yaw90[0].tolist()) + + # Create a kinematic rigid body at (0, 0, 2) rotated 90 deg around Z + body_path = "/World/DynamicBody" + sim_utils.create_prim(body_path, "Xform", translation=(0.0, 0.0, 2.0), orientation=yaw90_xyzw) + stage = sim_utils.get_current_stage() + body_prim = stage.GetPrimAtPath(body_path) + UsdPhysics.RigidBodyAPI.Apply(body_prim) + mass_api = UsdPhysics.MassAPI.Apply(body_prim) + mass_api.CreateMassAttr().Set(1.0) + body_prim.GetAttribute("physics:kinematicEnabled").Set(True) + + # Create a child Xform offset by (1, 0, 0) in the body's local frame, + # then place mesh geometry under it. The Xform translation is the offset + # that _obtain_trackable_prim_view / resolve_prim_pose will discover. + child_mesh_path = f"{body_path}/OffsetMesh" + sim_utils.create_prim(child_mesh_path, "Xform", translation=(1.0, 0.0, 0.0)) + mesh_data = make_plane(size=(2, 2), height=0.0, center_zero=True) + create_prim_from_mesh(f"{child_mesh_path}/Plane", mesh_data) + # Add collision so PhysX tracks the body + col_path = f"{body_path}/CollisionCube" + cube_geom = UsdGeom.Cube.Define(stage, col_path) + cube_geom.CreateSizeAttr().Set(0.1) + UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(col_path)) + sim_utils.update_stage() + + # Create a sensor prim to mount the MultiMeshRayCaster on + sensor_path = "/World/SensorMount" + sim_utils.create_prim(sensor_path, "Xform", translation=(0.0, 0.0, 5.0)) + + # Configure MultiMeshRayCaster to track the child mesh + cfg = MultiMeshRayCasterCfg( + prim_path=sensor_path, + mesh_prim_paths=[ + MultiMeshRayCasterCfg.RaycastTargetCfg( + prim_expr=child_mesh_path, + track_mesh_transforms=True, + ), + ], + update_period=0, + offset=MultiMeshRayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, 0.0, 0.0, 1.0)), + debug_vis=False, + pattern_cfg=patterns.GridPatternCfg(resolution=1.0, size=(0.0, 0.0), direction=(0.0, 0.0, -1.0)), + ray_alignment="world", + ) + sensor = MultiMeshRayCaster(cfg) + sim.reset() + sensor.update(_DT) + + # Verify mesh position: body at (0,0,2) rotated 90deg Z, child offset (1,0,0) local + # Expected: (0, 0, 2) + rotate(90degZ, (1,0,0)) = (0, 0, 2) + (0, 1, 0) = (0, 1, 2) + mesh_pos = sensor._mesh_positions_w_torch.clone() + np.testing.assert_allclose( + mesh_pos[0, 0].cpu().numpy(), + [0.0, 1.0, 2.0], + atol=0.15, + err_msg=( + "Mesh position should be (0, 1, 2) via proper frame decomposition: " + "body_pos + rotate(body_ori, local_offset). " + "If this fails, the offset is not being rotated by the body orientation." + ), + ) diff --git a/source/isaaclab/test/sensors/test_ray_caster_kernels.py b/source/isaaclab/test/sensors/test_ray_caster_kernels.py new file mode 100644 index 000000000000..cc57e4f1eec5 --- /dev/null +++ b/source/isaaclab/test/sensors/test_ray_caster_kernels.py @@ -0,0 +1,577 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for ray caster kernels. + +Tests for kernels in ``sensors/ray_caster/kernels.py`` and +``utils/warp/kernels.py``. Exercised directly with hand-crafted warp arrays +and analytically computed expected outputs. No simulation, no stage, no +AppLauncher -- just warp and numpy on CPU (or CUDA when available). + +See ``test_update_ray_caster_kernel.py`` for tests of +:func:`update_ray_caster_kernel`. +""" + +from __future__ import annotations + +import importlib.util +import math +import os + +import numpy as np +import pytest +import warp as wp + +# --------------------------------------------------------------------------- +# Import kernel modules directly (avoids Isaac Sim / Omniverse dependencies) +# --------------------------------------------------------------------------- + +_SENSOR_KERNEL_PATH = os.path.join( + os.path.dirname(__file__), + os.pardir, + os.pardir, + "isaaclab", + "sensors", + "ray_caster", + "kernels.py", +) +_spec = importlib.util.spec_from_file_location("ray_caster_kernels", os.path.normpath(_SENSOR_KERNEL_PATH)) +_sensor_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_sensor_mod) + +_WARP_KERNEL_PATH = os.path.join( + os.path.dirname(__file__), + os.pardir, + os.pardir, + "isaaclab", + "utils", + "warp", + "kernels.py", +) +_warp_spec = importlib.util.spec_from_file_location("warp_kernels", os.path.normpath(_WARP_KERNEL_PATH)) +_warp_mod = importlib.util.module_from_spec(_warp_spec) +_warp_spec.loader.exec_module(_warp_mod) + +compute_distance_to_image_plane_masked_kernel = _sensor_mod.compute_distance_to_image_plane_masked_kernel +apply_depth_clipping_masked_kernel = _sensor_mod.apply_depth_clipping_masked_kernel +apply_z_drift_kernel = _sensor_mod.apply_z_drift_kernel +quat_yaw_only = _sensor_mod.quat_yaw_only + +raycast_dynamic_meshes_kernel = _warp_mod.raycast_dynamic_meshes_kernel + +# --------------------------------------------------------------------------- +# Constants & setup +# --------------------------------------------------------------------------- + +wp.init() +DEVICE = "cuda:0" if wp.is_cuda_available() else "cpu" +ATOL = 1e-5 + + +# --------------------------------------------------------------------------- +# Wrapper kernel for quat_yaw_only (@wp.func cannot be launched directly) +# --------------------------------------------------------------------------- + + +@wp.kernel(enable_backward=False) +def _quat_yaw_only_test_kernel( + q_in: wp.array(dtype=wp.quatf), + q_out: wp.array(dtype=wp.quatf), +): + tid = wp.tid() + q_out[tid] = quat_yaw_only(q_in[tid]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _euler_to_quat_xyzw(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]: + """Euler angles (intrinsic XYZ) to quaternion in xyzw convention.""" + cr, sr = math.cos(roll / 2), math.sin(roll / 2) + cp, sp = math.cos(pitch / 2), math.sin(pitch / 2) + cy, sy = math.cos(yaw / 2), math.sin(yaw / 2) + qx = sr * cp * cy - cr * sp * sy + qy = cr * sp * cy + sr * cp * sy + qz = cr * cp * sy - sr * sp * cy + qw = cr * cp * cy + sr * sp * sy + return (qx, qy, qz, qw) + + +def _make_flat_mesh(size: float = 4.0) -> wp.Mesh: + """Create a flat square mesh in the XY plane at z=0, centered at origin.""" + half = size / 2.0 + vertices = np.array( + [[-half, -half, 0.0], [half, -half, 0.0], [half, half, 0.0], [-half, half, 0.0]], + dtype=np.float32, + ) + indices = np.array([0, 1, 2, 0, 2, 3], dtype=np.int32) + return wp.Mesh( + points=wp.array(vertices, dtype=wp.vec3, device=DEVICE), + indices=wp.array(indices, dtype=wp.int32, device=DEVICE), + ) + + +def _to_numpy(a: wp.array) -> np.ndarray: + """Convert a warp array to numpy, handling GPU arrays transparently.""" + return a.numpy() + + +# --------------------------------------------------------------------------- +# Tests: raycast_dynamic_meshes_kernel +# --------------------------------------------------------------------------- + + +class TestRaycastDynamicMeshesKernel: + """Tests for :func:`raycast_dynamic_meshes_kernel` from ``utils/warp/kernels.py``. + + Each test creates trivial warp meshes (flat quads) and verifies raycasting + results against analytical expectations. + """ + + IDENT_Q = [0.0, 0.0, 0.0, 1.0] + + @staticmethod + def _launch( + num_envs: int, + num_meshes: int, + num_rays: int, + env_mask: np.ndarray, + mesh_ids: np.ndarray, + ray_starts: np.ndarray, + ray_dirs: np.ndarray, + mesh_pos: np.ndarray, + mesh_rot: np.ndarray, + max_dist: float = 1e6, + sentinel: float | None = None, + ) -> dict[str, np.ndarray]: + """Build warp arrays, launch kernel, return outputs as numpy dicts.""" + env_mask_wp = wp.array(env_mask.astype(np.bool_), dtype=wp.bool, device=DEVICE) + mesh_wp = wp.array(mesh_ids, dtype=wp.uint64, device=DEVICE) + starts_wp = wp.array(ray_starts, dtype=wp.vec3f, device=DEVICE) + dirs_wp = wp.array(ray_dirs, dtype=wp.vec3f, device=DEVICE) + mpos_wp = wp.array(mesh_pos, dtype=wp.vec3f, device=DEVICE) + mrot_wp = wp.array(mesh_rot, dtype=wp.quatf, device=DEVICE) + + fill = sentinel if sentinel is not None else float("inf") + + hits_np = np.full((num_envs, num_rays, 3), fill, dtype=np.float32) + ray_hits = wp.array(hits_np, dtype=wp.vec3f, device=DEVICE) + + dist_np = np.full((num_envs, num_rays), fill, dtype=np.float32) + ray_distance = wp.array(dist_np, dtype=wp.float32, device=DEVICE) + + normal_np = np.full((num_envs, num_rays, 3), fill, dtype=np.float32) + ray_normal = wp.array(normal_np, dtype=wp.vec3f, device=DEVICE) + + face_np = np.full((num_envs, num_rays), -1, dtype=np.int32) + ray_face_id = wp.array(face_np, dtype=wp.int32, device=DEVICE) + + mesh_id_np = np.full((num_envs, num_rays), -1, dtype=np.int16) + ray_mesh_id = wp.array(mesh_id_np, dtype=wp.int16, device=DEVICE) + + wp.launch( + raycast_dynamic_meshes_kernel, + dim=(num_meshes, num_envs, num_rays), + inputs=[ + env_mask_wp, + mesh_wp, + starts_wp, + dirs_wp, + ray_hits, + ray_distance, + ray_normal, + ray_face_id, + ray_mesh_id, + mpos_wp, + mrot_wp, + max_dist, + 1, # return_normal + 1, # return_face_id + 1, # return_mesh_id + ], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + + return { + "hits": _to_numpy(ray_hits), + "distance": _to_numpy(ray_distance), + "normal": _to_numpy(ray_normal), + "face_id": _to_numpy(ray_face_id), + "mesh_id": _to_numpy(ray_mesh_id), + } + + def test_env_mask_skipping(self): + """Env 0 masked out -- verify output buffers retain sentinel values.""" + mesh = _make_flat_mesh() + iq = self.IDENT_Q + out = self._launch( + num_envs=2, + num_meshes=1, + num_rays=1, + env_mask=np.array([False, True]), + mesh_ids=np.array([[mesh.id], [mesh.id]], dtype=np.uint64), + ray_starts=np.array([[[0, 0, 10]], [[0, 0, 10]]], dtype=np.float32), + ray_dirs=np.array([[[0, 0, -1]], [[0, 0, -1]]], dtype=np.float32), + mesh_pos=np.array([[[0, 0, 2]], [[0, 0, 2]]], dtype=np.float32), + mesh_rot=np.array([[iq], [iq]], dtype=np.float32), + sentinel=999.0, + ) + + # Env 0 (masked): all outputs retain sentinel / initial fill + np.testing.assert_allclose(out["hits"][0, 0], [999, 999, 999], atol=ATOL) + assert out["distance"][0, 0] == pytest.approx(999.0, abs=ATOL) + np.testing.assert_allclose(out["normal"][0, 0], [999, 999, 999], atol=ATOL) + assert out["face_id"][0, 0] == -1 + assert out["mesh_id"][0, 0] == -1 + + # Env 1 (active): should have hit the mesh at z=2, distance 8 + np.testing.assert_allclose(out["hits"][1, 0], [0, 0, 2], atol=ATOL) + assert out["distance"][1, 0] == pytest.approx(8.0, abs=ATOL) + assert out["mesh_id"][1, 0] == 0 + + def test_closest_hit_overlapping_meshes(self): + """Two meshes at different distances -- closer hit wins. + + Mesh A at z=2 (farther), Mesh B at z=4 (closer to ray origin at z=10). + Ray from (0,0,10) going (0,0,-1). Expected: hit Mesh B at distance 6. + """ + mesh_a = _make_flat_mesh() + mesh_b = _make_flat_mesh() + iq = self.IDENT_Q + + out = self._launch( + num_envs=1, + num_meshes=2, + num_rays=1, + env_mask=np.array([True]), + mesh_ids=np.array([[mesh_a.id, mesh_b.id]], dtype=np.uint64), + ray_starts=np.array([[[0, 0, 10]]], dtype=np.float32), + ray_dirs=np.array([[[0, 0, -1]]], dtype=np.float32), + mesh_pos=np.array([[[0, 0, 2], [0, 0, 4]]], dtype=np.float32), + mesh_rot=np.array([[iq, iq]], dtype=np.float32), + ) + + np.testing.assert_allclose(out["hits"][0, 0], [0, 0, 4], atol=ATOL) + assert out["distance"][0, 0] == pytest.approx(6.0, abs=ATOL) + np.testing.assert_allclose(out["normal"][0, 0], [0, 0, 1], atol=ATOL) + assert out["mesh_id"][0, 0] == 1 # mesh_b is closer + + def test_mesh_transform_application(self): + """Mesh translated/rotated -- verify hits in correct world-space coordinates. + + Mesh: flat XY quad at z=0 (local), placed at world (5,0,0) with 90 deg + Y rotation. This turns it into a vertical plane at x=5. + Ray from (10,0,0) going (-1,0,0) should hit at (5,0,0), distance=5. + World-space normal: local (0,0,1) rotated by 90 deg Y = (1,0,0). + """ + mesh = _make_flat_mesh() + rot90y = [0.0, math.sin(math.pi / 4), 0.0, math.cos(math.pi / 4)] + + out = self._launch( + num_envs=1, + num_meshes=1, + num_rays=1, + env_mask=np.array([True]), + mesh_ids=np.array([[mesh.id]], dtype=np.uint64), + ray_starts=np.array([[[10, 0, 0]]], dtype=np.float32), + ray_dirs=np.array([[[-1, 0, 0]]], dtype=np.float32), + mesh_pos=np.array([[[5, 0, 0]]], dtype=np.float32), + mesh_rot=np.array([[rot90y]], dtype=np.float32), + ) + + np.testing.assert_allclose(out["hits"][0, 0], [5, 0, 0], atol=ATOL) + assert out["distance"][0, 0] == pytest.approx(5.0, abs=ATOL) + np.testing.assert_allclose(out["normal"][0, 0], [1, 0, 0], atol=ATOL) + + def test_equidistant_meshes(self): + """Two meshes at exact same distance -- hit position is always correct. + + Known limitation (warp#1058): when two meshes are equidistant, the + ``atomic_min`` + equality-check pattern is not fully thread-safe. + Normals, face_ids, and mesh_ids may come from either mesh. The hit + *position* is always correct because both threads compute the same + world-space point. + """ + mesh_a = _make_flat_mesh() + mesh_b = _make_flat_mesh() + iq = self.IDENT_Q + + out = self._launch( + num_envs=1, + num_meshes=2, + num_rays=1, + env_mask=np.array([True]), + mesh_ids=np.array([[mesh_a.id, mesh_b.id]], dtype=np.uint64), + ray_starts=np.array([[[0, 0, 10]]], dtype=np.float32), + ray_dirs=np.array([[[0, 0, -1]]], dtype=np.float32), + mesh_pos=np.array([[[0, 0, 3], [0, 0, 3]]], dtype=np.float32), + mesh_rot=np.array([[iq, iq]], dtype=np.float32), + ) + + # Position and distance are always correct, even under the race + np.testing.assert_allclose(out["hits"][0, 0], [0, 0, 3], atol=ATOL) + assert out["distance"][0, 0] == pytest.approx(7.0, abs=ATOL) + # mesh_id can be 0 or 1 -- both are valid under the race condition + assert out["mesh_id"][0, 0] in (0, 1) + + +# --------------------------------------------------------------------------- +# Tests: compute_distance_to_image_plane_masked_kernel +# --------------------------------------------------------------------------- + + +class TestComputeDistanceToImagePlaneMaskedKernel: + """Tests for :func:`compute_distance_to_image_plane_masked_kernel`.""" + + @staticmethod + def _launch( + quat_xyzw: list[float], + ray_distance: list[list[float]], + ray_dirs: list[list[list[float]]], + env_mask: list[bool] | None = None, + ) -> np.ndarray: + """Launch kernel and return distance_to_image_plane as numpy.""" + num_envs = len(ray_distance) + num_rays = len(ray_distance[0]) + if env_mask is None: + env_mask = [True] * num_envs + + mask_wp = wp.array(np.array(env_mask, dtype=np.bool_), dtype=wp.bool, device=DEVICE) + quat_np = np.array([quat_xyzw] * num_envs, dtype=np.float32) + quat_wp = wp.array(quat_np, dtype=wp.quatf, device=DEVICE) + ray_dist_wp = wp.array(np.array(ray_distance, dtype=np.float32), dtype=wp.float32, device=DEVICE) + dirs_wp = wp.array(np.array(ray_dirs, dtype=np.float32), dtype=wp.vec3f, device=DEVICE) + out_wp = wp.zeros((num_envs, num_rays), dtype=wp.float32, device=DEVICE) + + wp.launch( + compute_distance_to_image_plane_masked_kernel, + dim=(num_envs, num_rays), + inputs=[mask_wp, quat_wp, ray_dist_wp, dirs_wp], + outputs=[out_wp], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + return _to_numpy(out_wp) + + def test_known_camera_orientation(self): + """Identity camera, ray along +X at distance 5 -- d2ip equals 5.""" + result = self._launch( + quat_xyzw=[0, 0, 0, 1], + ray_distance=[[5.0]], + ray_dirs=[[[1, 0, 0]]], + ) + assert result[0, 0] == pytest.approx(5.0, abs=ATOL) + + def test_off_axis_camera(self): + """Camera pitched 45 deg around Y, ray going world -Z. + + Camera forward (+X_cam) in world = (cos45, 0, -sin45). + Displacement = 10 * (0, 0, -1) = (0, 0, -10). + Projection onto camera forward = dot((0,0,-10), (cos45,0,-sin45)) + = 10 * sin(45 deg). + """ + pitch45 = list(_euler_to_quat_xyzw(0, math.pi / 4, 0)) + result = self._launch( + quat_xyzw=pitch45, + ray_distance=[[10.0]], + ray_dirs=[[[0, 0, -1]]], + ) + expected = 10.0 * math.sin(math.pi / 4) + assert result[0, 0] == pytest.approx(expected, abs=ATOL) + + def test_inf_distance(self): + """Inf distance produces NaN through the projection (inf * 0 = NaN). + + When a ray misses, ray_distance is inf. Multiplying inf by zero-valued + ray-direction components yields NaN (IEEE 754), which propagates through + the quaternion rotation. The downstream + :func:`apply_depth_clipping_masked_kernel` handles NaN correctly via + ``wp.isnan()``, so the overall pipeline is sound. + """ + result = self._launch( + quat_xyzw=[0, 0, 0, 1], + ray_distance=[[float("inf")]], + ray_dirs=[[[1, 0, 0]]], + ) + assert np.isnan(result[0, 0]), f"Expected NaN from inf*0 contamination, got {result[0, 0]}" + + +# --------------------------------------------------------------------------- +# Tests: apply_depth_clipping_masked_kernel +# --------------------------------------------------------------------------- + + +class TestApplyDepthClippingMaskedKernel: + """Tests for :func:`apply_depth_clipping_masked_kernel`.""" + + @staticmethod + def _launch( + depth_values: list[list[float]], + max_dist: float, + fill_val: float, + env_mask: list[bool] | None = None, + ) -> np.ndarray: + """Launch kernel and return clipped depth as numpy.""" + num_envs = len(depth_values) + num_rays = len(depth_values[0]) + if env_mask is None: + env_mask = [True] * num_envs + + mask_wp = wp.array(np.array(env_mask, dtype=np.bool_), dtype=wp.bool, device=DEVICE) + depth_wp = wp.array(np.array(depth_values, dtype=np.float32), dtype=wp.float32, device=DEVICE) + + wp.launch( + apply_depth_clipping_masked_kernel, + dim=(num_envs, num_rays), + inputs=[mask_wp, max_dist, fill_val], + outputs=[depth_wp], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + return _to_numpy(depth_wp) + + def test_boundary_at_max_dist(self): + """Value at exactly max_dist is preserved (not clipped).""" + result = self._launch([[10.0]], max_dist=10.0, fill_val=0.0) + assert result[0, 0] == pytest.approx(10.0, abs=ATOL) + + def test_above_max_dist(self): + """Value above max_dist is replaced with fill_val.""" + result = self._launch([[10.001]], max_dist=10.0, fill_val=0.0) + assert result[0, 0] == pytest.approx(0.0, abs=ATOL) + + def test_nan_value(self): + """NaN value is replaced with fill_val.""" + result = self._launch([[float("nan")]], max_dist=10.0, fill_val=0.0) + assert result[0, 0] == pytest.approx(0.0, abs=ATOL) + + def test_inf_value(self): + """Inf is clipped (inf > max_dist is true).""" + result = self._launch([[float("inf")]], max_dist=10.0, fill_val=0.0) + assert result[0, 0] == pytest.approx(0.0, abs=ATOL) + + def test_negative_depth(self): + """Negative depth passes through unclipped (valid for distance-to-image-plane).""" + result = self._launch([[-3.5]], max_dist=10.0, fill_val=0.0) + assert result[0, 0] == pytest.approx(-3.5, abs=ATOL) + + def test_env_mask(self): + """Masked env retains original value -- clipping is not applied.""" + result = self._launch( + depth_values=[[15.0], [15.0]], + max_dist=10.0, + fill_val=0.0, + env_mask=[False, True], + ) + # Env 0 (masked): unchanged + assert result[0, 0] == pytest.approx(15.0, abs=ATOL) + # Env 1 (active): clipped + assert result[1, 0] == pytest.approx(0.0, abs=ATOL) + + def test_fill_val_zero_vs_max(self): + """fill_val=0.0 and fill_val=max_dist produce correct replacements.""" + max_dist = 10.0 + + result_zero = self._launch([[15.0]], max_dist=max_dist, fill_val=0.0) + assert result_zero[0, 0] == pytest.approx(0.0, abs=ATOL) + + result_max = self._launch([[15.0]], max_dist=max_dist, fill_val=max_dist) + assert result_max[0, 0] == pytest.approx(max_dist, abs=ATOL) + + +# --------------------------------------------------------------------------- +# Tests: apply_z_drift_kernel +# --------------------------------------------------------------------------- + + +class TestApplyZDriftKernel: + """Tests for :func:`apply_z_drift_kernel`.""" + + @staticmethod + def _launch( + hits: list[list[list[float]]], + drift: list[list[float]], + env_mask: list[bool] | None = None, + ) -> np.ndarray: + """Launch kernel and return modified ray_hits as numpy.""" + num_envs = len(hits) + num_rays = len(hits[0]) + if env_mask is None: + env_mask = [True] * num_envs + + mask_wp = wp.array(np.array(env_mask, dtype=np.bool_), dtype=wp.bool, device=DEVICE) + drift_wp = wp.array(np.array(drift, dtype=np.float32), dtype=wp.vec3f, device=DEVICE) + hits_wp = wp.array(np.array(hits, dtype=np.float32), dtype=wp.vec3f, device=DEVICE) + + wp.launch( + apply_z_drift_kernel, + dim=(num_envs, num_rays), + inputs=[mask_wp, drift_wp], + outputs=[hits_wp], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + return _to_numpy(hits_wp) + + def test_known_drift(self): + """ray_cast_drift = (0, 0, 1.5) shifts ray hit z by exactly 1.5.""" + result = self._launch( + hits=[[[3.0, 4.0, 5.0]]], + drift=[[0.0, 0.0, 1.5]], + ) + np.testing.assert_allclose(result[0, 0], [3.0, 4.0, 6.5], atol=ATOL) + + def test_only_z_component(self): + """Only z-component of drift is applied; x and y are unchanged.""" + result = self._launch( + hits=[[[3.0, 4.0, 5.0]]], + drift=[[0.5, 0.3, 1.0]], + ) + np.testing.assert_allclose(result[0, 0], [3.0, 4.0, 6.0], atol=ATOL) + + +# --------------------------------------------------------------------------- +# Tests: quat_yaw_only +# --------------------------------------------------------------------------- + + +class TestQuatYawOnly: + """Tests for :func:`quat_yaw_only` (a ``@wp.func`` tested via wrapper kernel).""" + + def test_gimbal_lock(self): + """At pitch = +/-pi/2, atan2 is near-degenerate but should produce a + finite, unit-norm, pure-yaw quaternion (only z and w components). + """ + q_down = _euler_to_quat_xyzw(0, math.pi / 2, 0) # pitch = +pi/2 + q_up = _euler_to_quat_xyzw(0, -math.pi / 2, 0) # pitch = -pi/2 + + q_in_np = np.array([list(q_down), list(q_up)], dtype=np.float32) + q_in = wp.array(q_in_np, dtype=wp.quatf, device=DEVICE) + q_out = wp.zeros(2, dtype=wp.quatf, device=DEVICE) + + wp.launch( + _quat_yaw_only_test_kernel, + dim=2, + inputs=[q_in], + outputs=[q_out], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + + result = _to_numpy(q_out) + + for i in range(2): + qx, qy, qz, qw = result[i] + # Must be finite (no NaN / inf) + assert np.isfinite(result[i]).all(), f"Non-finite output at index {i}: {result[i]}" + # Must be a pure-yaw quaternion: x ~ 0, y ~ 0 + assert abs(qx) < ATOL, f"x-component should be ~0 at gimbal lock, got {qx}" + assert abs(qy) < ATOL, f"y-component should be ~0 at gimbal lock, got {qy}" + # Must be unit-norm + norm = math.sqrt(float(qx) ** 2 + float(qy) ** 2 + float(qz) ** 2 + float(qw) ** 2) + assert norm == pytest.approx(1.0, abs=ATOL), f"Non-unit quaternion at index {i}: norm={norm}" diff --git a/source/isaaclab/test/sensors/test_ray_caster_sensor.py b/source/isaaclab/test/sensors/test_ray_caster_sensor.py new file mode 100644 index 000000000000..f1c90a986b5d --- /dev/null +++ b/source/isaaclab/test/sensors/test_ray_caster_sensor.py @@ -0,0 +1,272 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# pyright: reportPrivateUsage=none + +"""Tests for RayCaster sensor behavior: alignment modes and reset.""" + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True).app + +import numpy as np +import pytest +import torch +import warp as wp + +import isaaclab.sim as sim_utils +from isaaclab.sensors.ray_caster import RayCaster, RayCasterCfg, patterns +from isaaclab.terrains.trimesh.utils import make_plane +from isaaclab.terrains.utils import create_prim_from_mesh +from isaaclab.utils.math import quat_from_euler_xyz + +# ------------------------------------------------------------------- +# Helpers +# ------------------------------------------------------------------- + +_GROUND_PATH = "/World/Ground" + + +def _make_sim_and_ground(): + """Create a blank stage with a flat ground plane at z=0 and return the SimulationContext.""" + sim_utils.create_new_stage() + dt = 0.01 + sim_cfg = sim_utils.SimulationCfg(dt=dt) + sim = sim_utils.SimulationContext(sim_cfg) + mesh = make_plane(size=(100, 100), height=0.0, center_zero=True) + create_prim_from_mesh(_GROUND_PATH, mesh) + sim_utils.update_stage() + return sim + + +def _ray_caster_cfg(prim_path: str, alignment: str) -> RayCasterCfg: + """Single downward ray, no offset from prim.""" + return RayCasterCfg( + prim_path=prim_path, + mesh_prim_paths=[_GROUND_PATH], + update_period=0, + offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, 0.0, 0.0, 1.0)), + debug_vis=False, + pattern_cfg=patterns.GridPatternCfg(resolution=1.0, size=(0.0, 0.0), direction=(0.0, 0.0, -1.0)), + ray_alignment=alignment, + ) + + +@pytest.fixture +def sim_ground(): + sim = _make_sim_and_ground() + yield sim + sim.stop() + sim.clear_instance() + + +# ------------------------------------------------------------------- +# Alignment mode tests +# ------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_world_alignment_ignores_sensor_pitch(sim_ground): + """In 'world' alignment, ray direction is always (0,0,-1) regardless of sensor pitch. + + Two sensors at the same location: one upright (identity), one pitched 30°. + World-mode sensors must produce the same hit position (straight below at z=0). + """ + sim = sim_ground + + # Upright sensor: identity orientation + sim_utils.create_prim("/World/SensorUpright", "Xform", translation=(0.0, 0.0, 2.0)) + # Pitched 30° sensor — orientation=(x,y,z,w) per Isaac Lab convention + pitch_quat = quat_from_euler_xyz( + torch.tensor([0.0]), torch.tensor([np.pi / 6]), torch.tensor([0.0]) + ) # shape (1, 4), xyzw + sim_utils.create_prim( + "/World/SensorPitched", + "Xform", + translation=(0.0, 0.0, 2.0), + orientation=tuple(pitch_quat[0].tolist()), # xyzw + ) + + sensor_upright = RayCaster(_ray_caster_cfg("/World/SensorUpright", "world")) + sensor_pitched = RayCaster(_ray_caster_cfg("/World/SensorPitched", "world")) + sim.reset() + + dt = 0.01 + sensor_upright.update(dt) + sensor_pitched.update(dt) + + # ray_hits_w is a wp.array(dtype=wp.vec3f); convert to torch for indexing + hits_upright = wp.to_torch(sensor_upright.data.ray_hits_w) # (1, 1, 3) + hits_pitched = wp.to_torch(sensor_pitched.data.ray_hits_w) + + # Both must hit z=0 (straight down, world frame direction) + assert abs(hits_upright[0, 0, 2].item()) < 0.02, ( + f"Upright world sensor must hit z≈0, got {hits_upright[0, 0, 2].item()}" + ) + assert abs(hits_pitched[0, 0, 2].item()) < 0.02, ( + f"Pitched world sensor must hit z≈0, got {hits_pitched[0, 0, 2].item()}" + ) + # Lateral positions must agree (same start at [0,0,2] + same direction [0,0,-1]) + torch.testing.assert_close(hits_upright, hits_pitched, atol=0.02, rtol=0) + + +@pytest.mark.isaacsim_ci +def test_base_alignment_rotates_ray_direction(sim_ground): + """In 'base' alignment, ray direction follows the full sensor orientation. + + A sensor pitched +30° around Y (quat_from_euler_xyz(pitch=pi/6)): + - Rotates (0,0,-1) to (-sin(30°), 0, -cos(30°)) = (-0.5, 0, -0.866) + - world mode → ray still goes straight down, hits x≈0, z≈0 + - base mode → ray tilts, hits at x ≈ -2*tan(30°) ≈ -1.155 + """ + sim = sim_ground + + pitch_quat = quat_from_euler_xyz( + torch.tensor([0.0]), torch.tensor([np.pi / 6]), torch.tensor([0.0]) + ) # shape (1, 4), xyzw + orientation = tuple(pitch_quat[0].tolist()) + + sim_utils.create_prim("/World/SensorWorld", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) + sim_utils.create_prim("/World/SensorBase", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) + + sensor_world = RayCaster(_ray_caster_cfg("/World/SensorWorld", "world")) + sensor_base = RayCaster(_ray_caster_cfg("/World/SensorBase", "base")) + sim.reset() + + dt = 0.01 + sensor_world.update(dt) + sensor_base.update(dt) + + hits_world = wp.to_torch(sensor_world.data.ray_hits_w) # (1, 1, 3) + hits_base = wp.to_torch(sensor_base.data.ray_hits_w) + + # World mode: ray still hits directly below (x≈0, y≈0, z≈0) + assert abs(hits_world[0, 0, 0].item()) < 0.05, f"World mode hit x must be near 0, got {hits_world[0, 0, 0].item()}" + assert abs(hits_world[0, 0, 2].item()) < 0.05, f"World mode must hit z≈0, got {hits_world[0, 0, 2].item()}" + + # Base mode: pitch +30° around Y rotates (0,0,-1) to (-0.5, 0, -0.866). + # From height 2, the ray hits x = -2 * tan(30°) ≈ -1.155. + expected_x = -2.0 * np.tan(np.pi / 6) + assert abs(hits_base[0, 0, 0].item() - expected_x) < 0.05, ( + f"Base mode hit x should be ≈{expected_x:.3f}, got {hits_base[0, 0, 0].item():.3f}" + ) + assert abs(hits_base[0, 0, 2].item()) < 0.05, f"Base mode must hit ground (z≈0), got {hits_base[0, 0, 2].item()}" + + +@pytest.mark.isaacsim_ci +def test_yaw_alignment_direction_unchanged(sim_ground): + """In 'yaw' alignment, ray directions stay world-down despite pitch+roll. + + Setup: sensor at (0,0,2), pitched 30° and yawed 45°; pattern has a single ray + at local offset (+1, 0, 0). + + - world mode: start = sensor_pos + (1,0,0) (no rotation applied to offset) + - yaw mode: start = sensor_pos + yaw_rot(45°) @ (1,0,0) = (cos45°, sin45°, 0) + + Both modes fire the ray straight down (direction unchanged), so both hit z=0. + The hit x-coordinate differs between modes, confirming the yaw-only rotation of + start positions is applied in 'yaw' mode but not in 'world' mode. + """ + sim = sim_ground + + combined_quat = quat_from_euler_xyz( + torch.tensor([0.0]), + torch.tensor([np.pi / 6]), # 30° pitch + torch.tensor([np.pi / 4]), # 45° yaw + ) # shape (1, 4), xyzw + orientation = tuple(combined_quat[0].tolist()) + + sim_utils.create_prim("/World/SensorWorldY", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) + sim_utils.create_prim("/World/SensorYaw", "Xform", translation=(0.0, 0.0, 2.0), orientation=orientation) + + # Use a single ray at local offset (+1, 0, 0), still pointing down + def _cfg_with_offset(prim_path, alignment): + return RayCasterCfg( + prim_path=prim_path, + mesh_prim_paths=[_GROUND_PATH], + update_period=0, + offset=RayCasterCfg.OffsetCfg(pos=(1.0, 0.0, 0.0), rot=(0.0, 0.0, 0.0, 1.0)), + debug_vis=False, + pattern_cfg=patterns.GridPatternCfg(resolution=1.0, size=(0.0, 0.0), direction=(0.0, 0.0, -1.0)), + ray_alignment=alignment, + ) + + sensor_world = RayCaster(_cfg_with_offset("/World/SensorWorldY", "world")) + sensor_yaw = RayCaster(_cfg_with_offset("/World/SensorYaw", "yaw")) + sim.reset() + + dt = 0.01 + sensor_world.update(dt) + sensor_yaw.update(dt) + + hits_world = wp.to_torch(sensor_world.data.ray_hits_w) # (1, 1, 3) + hits_yaw = wp.to_torch(sensor_yaw.data.ray_hits_w) + + # Both modes must hit the ground (direction unchanged = straight down in both modes) + assert abs(hits_world[0, 0, 2].item()) < 0.05, "World mode must hit z≈0" + assert abs(hits_yaw[0, 0, 2].item()) < 0.05, "Yaw mode must hit z≈0 (direction straight down)" + + # world mode: offset (1,0,0) not rotated → ray starts at sensor_pos+(1,0,0) → hits x≈1 + assert abs(hits_world[0, 0, 0].item() - 1.0) < 0.05, ( + f"World mode: hit x should be ≈1.0 (unrotated offset), got {hits_world[0, 0, 0].item():.3f}" + ) + + # yaw mode: offset (1,0,0) rotated by 45° yaw → starts at sensor_pos+(cos45°, sin45°, 0) → hits x≈cos45° + expected_x_yaw = np.cos(np.pi / 4) # ≈ 0.707 + assert abs(hits_yaw[0, 0, 0].item() - expected_x_yaw) < 0.05, ( + f"Yaw mode: hit x should be ≈{expected_x_yaw:.3f} (yaw-rotated offset), got {hits_yaw[0, 0, 0].item():.3f}" + ) + # Confirm they differ — if they were the same, the test would not cover the yaw rotation + assert not torch.allclose(hits_world, hits_yaw, atol=0.1), ( + "Yaw and world modes must produce different hit positions for non-zero lateral offset" + ) + + +# ------------------------------------------------------------------- +# Reset / drift test +# ------------------------------------------------------------------- + + +@pytest.mark.isaacsim_ci +def test_ray_caster_reset_resamples_drift(sim_ground): + """reset() resamples drift values within the configured drift_range.""" + sim = sim_ground + + sim_utils.create_prim("/World/Sensor", "Xform", translation=(0.0, 0.0, 2.0)) + cfg = _ray_caster_cfg("/World/Sensor", "world") + cfg.drift_range = (0.01, 0.05) # force non-zero drift + sensor = RayCaster(cfg) + sim.reset() + # sim.reset() initializes the sensor with zero drift; call sensor.reset() to resample + # from the configured drift_range before we capture the baseline. + sensor.reset() + + dt = 0.01 + sensor.update(dt) + drift_before = sensor.drift.clone() # (1, 3) torch tensor + + lo, hi = cfg.drift_range + + # After sensor.reset(), drift should be within the configured range + assert drift_before.shape == (1, 3), f"Drift shape should be (1, 3), got {drift_before.shape}" + assert (drift_before >= lo - 1e-6).all() and (drift_before <= hi + 1e-6).all(), ( + f"Initial drift must be in [{lo}, {hi}], got [{drift_before.min():.4f}, {drift_before.max():.4f}]" + ) + + # reset() resamples drift; values should remain within the configured range + # Call reset() multiple times until we get a different sample (probability of same is near zero + # for continuous uniform distribution, but we retry to avoid flakiness). + for _ in range(5): + sensor.reset() + drift_after = sensor.drift.clone() + if not torch.allclose(drift_after, drift_before): + break + assert drift_after.shape == drift_before.shape, "Drift shape must be preserved after reset" + assert (drift_after >= lo - 1e-6).all() and (drift_after <= hi + 1e-6).all(), ( + f"Drift after reset must be in [{lo}, {hi}], got [{drift_after.min():.4f}, {drift_after.max():.4f}]" + ) + assert not torch.allclose(drift_after, drift_before), ( + "reset() must resample drift; values must change from initial sample" + ) diff --git a/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py b/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py new file mode 100644 index 000000000000..65402518b7a0 --- /dev/null +++ b/source/isaaclab/test/sensors/test_update_ray_caster_kernel.py @@ -0,0 +1,510 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for :func:`update_ray_caster_kernel`. + +These tests exercise the kernel directly with hand-crafted warp arrays and +analytically computed expected outputs. No simulation, no stage, no AppLauncher +— just warp on CPU (or CUDA when available). +""" + +from __future__ import annotations + +import importlib.util +import math +import os + +import numpy as np +import pytest +import torch +import warp as wp + +# Import the kernel module directly to avoid pulling in the full isaaclab package +# (which requires Isaac Sim / Omniverse dependencies). The kernel file itself only +# depends on warp. +_KERNEL_PATH = os.path.join( + os.path.dirname(__file__), + os.pardir, + os.pardir, + "isaaclab", + "sensors", + "ray_caster", + "kernels.py", +) +_spec = importlib.util.spec_from_file_location("ray_caster_kernels", os.path.normpath(_KERNEL_PATH)) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +update_ray_caster_kernel = _mod.update_ray_caster_kernel +ALIGNMENT_WORLD = _mod.ALIGNMENT_WORLD +ALIGNMENT_YAW = _mod.ALIGNMENT_YAW +ALIGNMENT_BASE = _mod.ALIGNMENT_BASE + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +wp.init() +DEVICE = "cuda:0" if wp.is_cuda_available() else "cpu" +TORCH_DEVICE = torch.device(DEVICE) +ATOL = 1e-5 + + +def _make_transform(pos: tuple[float, float, float], quat_xyzw: tuple[float, float, float, float]) -> wp.array: + """Create a warp transformf array (1,) from position and xyzw quaternion.""" + t = torch.tensor([[pos[0], pos[1], pos[2], quat_xyzw[0], quat_xyzw[1], quat_xyzw[2], quat_xyzw[3]]], device=DEVICE) + return wp.from_torch(t.contiguous()).view(wp.transformf) + + +def _identity_quat() -> tuple[float, float, float, float]: + """Return identity quaternion in xyzw.""" + return (0.0, 0.0, 0.0, 1.0) + + +def _yaw_quat(yaw_rad: float) -> tuple[float, float, float, float]: + """Pure yaw quaternion in xyzw.""" + return (0.0, 0.0, math.sin(yaw_rad / 2), math.cos(yaw_rad / 2)) + + +def _euler_to_quat_xyzw(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]: + """Euler angles (intrinsic XYZ) to quaternion in xyzw convention.""" + q = torch.zeros(1, 4) + cr, sr = math.cos(roll / 2), math.sin(roll / 2) + cp, sp = math.cos(pitch / 2), math.sin(pitch / 2) + cy, sy = math.cos(yaw / 2), math.sin(yaw / 2) + # xyzw + q[0, 0] = sr * cp * cy - cr * sp * sy + q[0, 1] = cr * sp * cy + sr * cp * sy + q[0, 2] = cr * cp * sy - sr * sp * cy + q[0, 3] = cr * cp * cy + sr * sp * sy + return tuple(q[0].tolist()) + + +def _quat_rotate(q_xyzw: tuple, v: tuple) -> np.ndarray: + """Rotate vector v by quaternion q (xyzw) using numpy.""" + qx, qy, qz, qw = q_xyzw + # quaternion rotation: v' = q * v * q^-1 + # Using the formula: v' = v + 2*w*(w×v) + 2*(q_vec × (q_vec × v + w*v)) + # Simpler: v' = v + 2w(q×v) + 2(q×(q×v)) + q_vec = np.array([qx, qy, qz]) + v = np.array(v) + t = 2.0 * np.cross(q_vec, v) + return v + qw * t + np.cross(q_vec, t) + + +def _launch_kernel( + transforms: wp.array, + env_mask: wp.array, + offset_pos: wp.array, + offset_quat: wp.array, + drift: wp.array, + ray_cast_drift: wp.array, + ray_starts_local: wp.array, + ray_directions_local: wp.array, + alignment_mode: int, + num_envs: int, + num_rays: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Launch the kernel and return (pos_w, quat_w, ray_starts_w, ray_directions_w) as numpy arrays.""" + pos_w = wp.zeros(num_envs, dtype=wp.vec3f, device=DEVICE) + quat_w = wp.zeros(num_envs, dtype=wp.quatf, device=DEVICE) + ray_starts_w = wp.zeros((num_envs, num_rays), dtype=wp.vec3f, device=DEVICE) + ray_directions_w = wp.zeros((num_envs, num_rays), dtype=wp.vec3f, device=DEVICE) + + wp.launch( + update_ray_caster_kernel, + dim=(num_envs, num_rays), + inputs=[ + transforms, + env_mask, + offset_pos, + offset_quat, + drift, + ray_cast_drift, + ray_starts_local, + ray_directions_local, + alignment_mode, + ], + outputs=[pos_w, quat_w, ray_starts_w, ray_directions_w], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + + return ( + wp.to_torch(pos_w).cpu().numpy(), + wp.to_torch(quat_w).cpu().numpy(), + wp.to_torch(ray_starts_w).cpu().numpy(), + wp.to_torch(ray_directions_w).cpu().numpy(), + ) + + +def _make_inputs( + view_pos=(0.0, 0.0, 0.0), + view_quat=None, + offset_pos=(0.0, 0.0, 0.0), + offset_quat=None, + drift=(0.0, 0.0, 0.0), + ray_cast_drift=(0.0, 0.0, 0.0), + ray_start=(0.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + num_envs=1, +): + """Build all kernel input arrays for a single-ray, single (or multi)-env scenario.""" + if view_quat is None: + view_quat = _identity_quat() + if offset_quat is None: + offset_quat = _identity_quat() + + transforms = _make_transform(view_pos, view_quat) + if num_envs > 1: + # Replicate the same transform for all envs + t_torch = wp.to_torch(transforms).repeat(num_envs, 1) + transforms = wp.from_torch(t_torch.contiguous()).view(wp.transformf) + + mask_t = torch.ones(num_envs, dtype=torch.bool, device=TORCH_DEVICE) + env_mask = wp.from_torch(mask_t) + + op = torch.tensor( + [[offset_pos[0], offset_pos[1], offset_pos[2]]] * num_envs, dtype=torch.float32, device=TORCH_DEVICE + ) + offset_pos_wp = wp.from_torch(op.contiguous(), dtype=wp.vec3f) + + oq = torch.tensor( + [[offset_quat[0], offset_quat[1], offset_quat[2], offset_quat[3]]] * num_envs, + dtype=torch.float32, + device=TORCH_DEVICE, + ) + offset_quat_wp = wp.from_torch(oq.contiguous(), dtype=wp.quatf) + + d = torch.tensor([[drift[0], drift[1], drift[2]]] * num_envs, dtype=torch.float32, device=TORCH_DEVICE) + drift_wp = wp.from_torch(d.contiguous(), dtype=wp.vec3f) + + rcd = torch.tensor( + [[ray_cast_drift[0], ray_cast_drift[1], ray_cast_drift[2]]] * num_envs, dtype=torch.float32, device=TORCH_DEVICE + ) + rcd_wp = wp.from_torch(rcd.contiguous(), dtype=wp.vec3f) + + rs = torch.tensor( + [[[ray_start[0], ray_start[1], ray_start[2]]]] * num_envs, dtype=torch.float32, device=TORCH_DEVICE + ) + rs_wp = wp.from_torch(rs.contiguous(), dtype=wp.vec3f) + + rd = torch.tensor([[[ray_dir[0], ray_dir[1], ray_dir[2]]]] * num_envs, dtype=torch.float32, device=TORCH_DEVICE) + rd_wp = wp.from_torch(rd.contiguous(), dtype=wp.vec3f) + + return transforms, env_mask, offset_pos_wp, offset_quat_wp, drift_wp, rcd_wp, rs_wp, rd_wp + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestUpdateRayCasterKernel: + """Unit tests for update_ray_caster_kernel launched directly with warp arrays.""" + + def test_identity_passthrough(self): + """All identity/zero inputs → pos_w = origin, quat_w = identity, rays unchanged.""" + inputs = _make_inputs(ray_start=(1.0, 2.0, 3.0), ray_dir=(0.0, 0.0, -1.0)) + pos_w, quat_w, starts_w, dirs_w = _launch_kernel(*inputs, alignment_mode=0, num_envs=1, num_rays=1) + + np.testing.assert_allclose(pos_w[0], [0, 0, 0], atol=ATOL) + np.testing.assert_allclose(quat_w[0], [0, 0, 0, 1], atol=ATOL) + # World mode, identity: ray_start_w = local_start + pos (= local_start + origin) + np.testing.assert_allclose(starts_w[0, 0], [1, 2, 3], atol=ATOL) + np.testing.assert_allclose(dirs_w[0, 0], [0, 0, -1], atol=ATOL) + + # Same for yaw and base — all should agree at identity + for mode in [1, 2]: + inputs = _make_inputs(ray_start=(1.0, 2.0, 3.0), ray_dir=(0.0, 0.0, -1.0)) + _, _, starts_w2, dirs_w2 = _launch_kernel(*inputs, alignment_mode=mode, num_envs=1, num_rays=1) + np.testing.assert_allclose(starts_w2[0, 0], [1, 2, 3], atol=ATOL) + np.testing.assert_allclose(dirs_w2[0, 0], [0, 0, -1], atol=ATOL) + + def test_offset_composition(self): + """View at (1,0,2) yawed 90° + offset (0,1,0) → combined_pos = (1,-1,2). + + 90° yaw: quat = (0, 0, sin(45°), cos(45°)) = (0, 0, 0.7071, 0.7071) + quat_rotate(90°yaw, (0,1,0)) = (-1, 0, 0) [Y axis maps to -X] + combined_pos = (1,0,2) + (-1,0,0) = (0,0,2) + combined_quat = yaw90 * identity = yaw90 + """ + yaw90 = _yaw_quat(math.pi / 2) + inputs = _make_inputs( + view_pos=(1.0, 0.0, 2.0), + view_quat=yaw90, + offset_pos=(0.0, 1.0, 0.0), + ) + pos_w, quat_w, _, _ = _launch_kernel(*inputs, alignment_mode=2, num_envs=1, num_rays=1) + + expected_offset_rotated = _quat_rotate(yaw90, (0, 1, 0)) # (-1, 0, 0) + expected_pos = np.array([1, 0, 2]) + expected_offset_rotated + np.testing.assert_allclose(pos_w[0], expected_pos, atol=ATOL) + np.testing.assert_allclose(quat_w[0], list(yaw90), atol=ATOL) + + def test_world_alignment_ignores_rotation(self): + """World mode: ray starts = local_start + combined_pos, directions unchanged. + + Sensor at (0,0,5), pitched 45° around Y. Local ray at (+1,0,0), direction (0,0,-1). + World mode should NOT rotate the ray start or direction. + """ + pitch45 = _euler_to_quat_xyzw(0, math.pi / 4, 0) + inputs = _make_inputs( + view_pos=(0.0, 0.0, 5.0), + view_quat=pitch45, + ray_start=(1.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + ) + pos_w, _, starts_w, dirs_w = _launch_kernel(*inputs, alignment_mode=0, num_envs=1, num_rays=1) + + # ray_start_w = local_start + combined_pos = (1,0,0) + (0,0,5) = (1,0,5) + np.testing.assert_allclose(starts_w[0, 0], [1, 0, 5], atol=ATOL) + # direction unchanged + np.testing.assert_allclose(dirs_w[0, 0], [0, 0, -1], atol=ATOL) + + def test_yaw_alignment_rotates_starts_only(self): + """Yaw mode: ray starts rotated by yaw-only quaternion, directions unchanged. + + Sensor yawed 90° + pitched 30°. Local ray start at (+1, 0, 0). + Yaw-only extracts 90° yaw → rotates (+1,0,0) to (0,+1,0). + Direction (0,0,-1) is NOT rotated in yaw mode. + """ + q = _euler_to_quat_xyzw(0, math.pi / 6, math.pi / 2) # pitch 30°, yaw 90° + inputs = _make_inputs( + view_pos=(0.0, 0.0, 3.0), + view_quat=q, + ray_start=(1.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + ) + pos_w, _, starts_w, dirs_w = _launch_kernel(*inputs, alignment_mode=1, num_envs=1, num_rays=1) + + # yaw-only of 90° yaw + 30° pitch → pure 90° yaw + yaw_only = _yaw_quat(math.pi / 2) + rotated_start = _quat_rotate(yaw_only, (1, 0, 0)) # (0, 1, 0) + expected_start = rotated_start + np.array([0, 0, 3]) # + combined_pos + np.testing.assert_allclose(starts_w[0, 0], expected_start, atol=ATOL) + + # direction unchanged in yaw mode + np.testing.assert_allclose(dirs_w[0, 0], [0, 0, -1], atol=ATOL) + + def test_base_alignment_rotates_starts_and_directions(self): + """Base mode: both ray starts and directions rotated by full combined quaternion. + + Sensor yawed 90°. Local ray at (+1, 0, 0), direction (0, 0, -1). + 90° yaw rotates: + (+1,0,0) → (0,+1,0) + (0,0,-1) → (0,0,-1) [yaw doesn't affect Z-down] + """ + yaw90 = _yaw_quat(math.pi / 2) + inputs = _make_inputs( + view_pos=(0.0, 0.0, 4.0), + view_quat=yaw90, + ray_start=(1.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + ) + _, _, starts_w, dirs_w = _launch_kernel(*inputs, alignment_mode=2, num_envs=1, num_rays=1) + + rotated_start = _quat_rotate(yaw90, (1, 0, 0)) # (0, 1, 0) + expected_start = rotated_start + np.array([0, 0, 4]) + np.testing.assert_allclose(starts_w[0, 0], expected_start, atol=ATOL) + + rotated_dir = _quat_rotate(yaw90, (0, 0, -1)) # (0, 0, -1) — Z unaffected by yaw + np.testing.assert_allclose(dirs_w[0, 0], rotated_dir, atol=ATOL) + + def test_base_alignment_with_pitch_rotates_direction(self): + """Base mode with pitch: direction is rotated by the full orientation. + + Sensor pitched 90° around Y (looking forward instead of down). + Direction (0,0,-1) rotated by 90° pitch around Y → (-1,0,0). + """ + pitch90 = _euler_to_quat_xyzw(0, math.pi / 2, 0) + inputs = _make_inputs( + view_pos=(0.0, 0.0, 2.0), + view_quat=pitch90, + ray_start=(0.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + ) + _, _, _, dirs_w = _launch_kernel(*inputs, alignment_mode=2, num_envs=1, num_rays=1) + + rotated_dir = _quat_rotate(pitch90, (0, 0, -1)) # (-1, 0, 0) + np.testing.assert_allclose(dirs_w[0, 0], rotated_dir, atol=ATOL) + + def test_ray_cast_drift_world_mode(self): + """World mode: ray_cast_drift XY is added raw to position, Z is NOT applied. + + drift = (0.5, 0.3, 0.7). In world mode: + pos_drifted = (combined_pos.x + 0.5, combined_pos.y + 0.3, combined_pos.z) + Note: Z component of ray_cast_drift is NOT added to position in any mode. + """ + inputs = _make_inputs( + view_pos=(1.0, 2.0, 3.0), + ray_cast_drift=(0.5, 0.3, 0.7), + ray_start=(0.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + ) + _, _, starts_w, dirs_w = _launch_kernel(*inputs, alignment_mode=0, num_envs=1, num_rays=1) + + # World mode: pos_drifted = (1+0.5, 2+0.3, 3) = (1.5, 2.3, 3) + # ray_start_w = local_start + pos_drifted = (0,0,0) + (1.5, 2.3, 3) + np.testing.assert_allclose(starts_w[0, 0], [1.5, 2.3, 3.0], atol=ATOL) + np.testing.assert_allclose(dirs_w[0, 0], [0, 0, -1], atol=ATOL) + + def test_ray_cast_drift_yaw_mode(self): + """Yaw mode: ray_cast_drift XY is rotated by yaw-only quat, Z is NOT applied. + + Sensor yawed 90°, drift = (1.0, 0.0, 0.5). + yaw-rotated drift = quat_rotate(yaw90, (1,0,0.5)) — but only XY of the result + is used for pos_drifted. Actually looking at the kernel: + rot_drift = quat_rotate(yaw_q, rcd) # full rotation of the drift vector + pos_drifted = (combined_pos.x + rot_drift.x, combined_pos.y + rot_drift.y, combined_pos.z) + So the drift vector is fully rotated, but only XY of the result is added. + """ + yaw90 = _yaw_quat(math.pi / 2) + inputs = _make_inputs( + view_pos=(0.0, 0.0, 5.0), + view_quat=yaw90, + ray_cast_drift=(1.0, 0.0, 0.5), + ray_start=(0.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + ) + _, _, starts_w, _ = _launch_kernel(*inputs, alignment_mode=1, num_envs=1, num_rays=1) + + # yaw90 rotates (1, 0, 0.5) → (0, 1, 0.5) [X→Y under 90° yaw, Z unchanged] + rot_drift = _quat_rotate(yaw90, (1, 0, 0.5)) + # pos_drifted = (0 + rot_drift.x, 0 + rot_drift.y, 5) — Z from combined_pos + expected_start = np.array([rot_drift[0], rot_drift[1], 5.0]) + # local_start = (0,0,0), rotated by yaw_q → still (0,0,0) + np.testing.assert_allclose(starts_w[0, 0], expected_start, atol=ATOL) + + def test_ray_cast_drift_base_mode(self): + """Base mode: ray_cast_drift XY is rotated by full combined_quat, Z is NOT applied. + + Sensor pitched 90° around Y, drift = (1.0, 0.0, 0.0). + Full rotation of (1,0,0) by 90° pitch around Y → (0, 0, -1). + pos_drifted = (combined_pos.x + 0, combined_pos.y + 0, combined_pos.z) — both XY of + rotated drift happen to be 0 in this case. + """ + pitch90 = _euler_to_quat_xyzw(0, math.pi / 2, 0) + inputs = _make_inputs( + view_pos=(0.0, 0.0, 5.0), + view_quat=pitch90, + ray_cast_drift=(1.0, 0.0, 0.0), + ray_start=(0.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + ) + _, _, starts_w, _ = _launch_kernel(*inputs, alignment_mode=2, num_envs=1, num_rays=1) + + rot_drift = _quat_rotate(pitch90, (1, 0, 0)) # (0, 0, -1) + # pos_drifted = (0 + rot_drift.x, 0 + rot_drift.y, 5) = (0, 0, 5) + # local_start (0,0,0) rotated by pitch90 → still (0,0,0) + expected_start = np.array([rot_drift[0], rot_drift[1], 5.0]) + np.testing.assert_allclose(starts_w[0, 0], expected_start, atol=ATOL) + + def test_env_mask_skips_masked_envs(self): + """Masked-out environments retain sentinel values in output buffers. + + 2 envs, env 0 masked out (False), env 1 active (True). + Output buffers are pre-filled with sentinel (999). After kernel launch, + env 0 should still have 999, env 1 should have computed values. + """ + yaw90 = _yaw_quat(math.pi / 2) + + # Build transforms for 2 envs: both at (0,0,2) with yaw90 + t_single = torch.tensor( + [[0, 0, 2, yaw90[0], yaw90[1], yaw90[2], yaw90[3]]], + dtype=torch.float32, + device=DEVICE, + ) + t_both = t_single.repeat(2, 1).contiguous() + transforms = wp.from_torch(t_both).view(wp.transformf) + + # Mask: env 0 = False, env 1 = True + mask_t = torch.tensor([False, True], dtype=torch.bool, device=TORCH_DEVICE) + env_mask = wp.from_torch(mask_t) + + # Zero offsets and drifts for both envs + zero3 = torch.zeros(2, 3, dtype=torch.float32, device=TORCH_DEVICE) + offset_pos_wp = wp.from_torch(zero3.clone().contiguous(), dtype=wp.vec3f) + iq = torch.tensor([[0, 0, 0, 1]] * 2, dtype=torch.float32, device=TORCH_DEVICE) + offset_quat_wp = wp.from_torch(iq.contiguous(), dtype=wp.quatf) + drift_wp = wp.from_torch(zero3.clone().contiguous(), dtype=wp.vec3f) + rcd_wp = wp.from_torch(zero3.clone().contiguous(), dtype=wp.vec3f) + + # Single ray per env + rs = torch.tensor([[[1, 0, 0]]] * 2, dtype=torch.float32, device=TORCH_DEVICE) + rs_wp = wp.from_torch(rs.contiguous(), dtype=wp.vec3f) + rd = torch.tensor([[[0, 0, -1]]] * 2, dtype=torch.float32, device=TORCH_DEVICE) + rd_wp = wp.from_torch(rd.contiguous(), dtype=wp.vec3f) + + # Pre-fill outputs with sentinel + sentinel = 999.0 + pos_w_t = torch.full((2, 3), sentinel, dtype=torch.float32, device=TORCH_DEVICE) + pos_w = wp.from_torch(pos_w_t.contiguous(), dtype=wp.vec3f) + quat_w_t = torch.full((2, 4), sentinel, dtype=torch.float32, device=TORCH_DEVICE) + quat_w = wp.from_torch(quat_w_t.contiguous(), dtype=wp.quatf) + starts_w_t = torch.full((2, 1, 3), sentinel, dtype=torch.float32, device=TORCH_DEVICE) + starts_w = wp.from_torch(starts_w_t.contiguous(), dtype=wp.vec3f) + dirs_w_t = torch.full((2, 1, 3), sentinel, dtype=torch.float32, device=TORCH_DEVICE) + dirs_w = wp.from_torch(dirs_w_t.contiguous(), dtype=wp.vec3f) + + wp.launch( + update_ray_caster_kernel, + dim=(2, 1), + inputs=[transforms, env_mask, offset_pos_wp, offset_quat_wp, drift_wp, rcd_wp, rs_wp, rd_wp, 2], + outputs=[pos_w, quat_w, starts_w, dirs_w], + device=DEVICE, + ) + wp.synchronize_device(DEVICE) + + pos_np = wp.to_torch(pos_w).cpu().numpy() + quat_np = wp.to_torch(quat_w).cpu().numpy() + starts_np = wp.to_torch(starts_w).cpu().numpy() + dirs_np = wp.to_torch(dirs_w).cpu().numpy() + + # Env 0 (masked): all outputs should still be sentinel + np.testing.assert_allclose(pos_np[0], [sentinel] * 3, atol=ATOL) + np.testing.assert_allclose(quat_np[0], [sentinel] * 4, atol=ATOL) + np.testing.assert_allclose(starts_np[0, 0], [sentinel] * 3, atol=ATOL) + np.testing.assert_allclose(dirs_np[0, 0], [sentinel] * 3, atol=ATOL) + + # Env 1 (active): should have computed values + np.testing.assert_allclose(pos_np[1], [0, 0, 2], atol=ATOL) + np.testing.assert_allclose(quat_np[1], list(yaw90), atol=ATOL) + # Base mode: (1,0,0) rotated by yaw90 = (0,1,0), + pos (0,0,2) + expected_start = _quat_rotate(yaw90, (1, 0, 0)) + np.array([0, 0, 2]) + np.testing.assert_allclose(starts_np[1, 0], expected_start, atol=ATOL) + expected_dir = _quat_rotate(yaw90, (0, 0, -1)) # (0, 0, -1) unaffected by yaw + np.testing.assert_allclose(dirs_np[1, 0], expected_dir, atol=ATOL) + + def test_positional_drift_added_before_alignment(self): + """The `drift` parameter is added to combined_pos before ray transformation. + + Verify that drift shifts the sensor position (and therefore ray starts) + equally across all alignment modes. + """ + drift_val = (0.0, 0.0, 1.5) # shift up 1.5m + results = {} + for mode_name, mode_int in [("world", 0), ("yaw", 1), ("base", 2)]: + inputs = _make_inputs( + view_pos=(0.0, 0.0, 3.0), + drift=drift_val, + ray_start=(0.0, 0.0, 0.0), + ray_dir=(0.0, 0.0, -1.0), + ) + pos_w, _, starts_w, _ = _launch_kernel(*inputs, alignment_mode=mode_int, num_envs=1, num_rays=1) + results[mode_name] = (pos_w, starts_w) + + # All modes: pos_w should be (0, 0, 4.5) = view_pos + drift + for mode_name in ["world", "yaw", "base"]: + np.testing.assert_allclose( + results[mode_name][0][0], + [0, 0, 4.5], + atol=ATOL, + err_msg=f"{mode_name} mode: pos_w should include drift", + ) + # ray_start_w Z should also reflect the drifted position + assert results[mode_name][1][0, 0, 2] == pytest.approx(4.5, abs=ATOL), ( + f"{mode_name} mode: ray start Z should be 4.5" + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py index ea95dfe5b98d..e58e377a0d63 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py @@ -88,7 +88,9 @@ def _get_observations(self) -> dict: height_data = None if isinstance(self.cfg, AnymalCRoughEnvCfg): height_data = ( - self._height_scanner.data.pos_w[:, 2].unsqueeze(1) - self._height_scanner.data.ray_hits_w[..., 2] - 0.5 + wp.to_torch(self._height_scanner.data.pos_w)[:, 2].unsqueeze(1) + - wp.to_torch(self._height_scanner.data.ray_hits_w)[..., 2] + - 0.5 ).clip(-1.0, 1.0) obs = torch.cat( [ From d1cb8e887d6c89619258ad598aa1785494fe5373 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Wed, 22 Apr 2026 17:43:20 +0200 Subject: [PATCH 22/37] Port WrenchComposer dual-buffer fix to develop branch (#5265) # Description Replaces the single-buffer WrenchComposer with a dual-buffer architecture that stores global (world-frame) and local (body-frame) forces separately. Follows develop's warp-first paradigm: all internal buffers are warp arrays, inputs accept both torch.Tensor and wp.array (warp ingests both natively), outputs are wp.array. Updates PhysX and Newton asset write_data_to_sim to use add_raw_buffers_from + compose_to_body_frame instead of the old composed_force/composed_torque merge pattern. ## Type of change - Bug fix (non-breaking change which fixes an issue) - ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: ClemensSchwarke --- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 38 + .../assets/articulation/base_articulation.py | 11 +- .../assets/rigid_object/base_rigid_object.py | 11 +- .../base_rigid_object_collection.py | 18 +- .../utils/mock_wrench_composer.py | 230 +++- .../isaaclab/isaaclab/utils/warp/kernels.py | 533 ++++---- .../isaaclab/utils/wrench_composer.py | 637 +++++++--- .../test/utils/test_wrench_composer.py | 1121 ++++++++++++++++- .../utils/test_wrench_composer_integration.py | 817 ++++++++++++ .../utils/test_wrench_composer_vs_physx.py | 837 ++++++++++++ source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 12 + .../assets/articulation/articulation.py | 49 +- .../assets/rigid_object/rigid_object.py | 49 +- .../rigid_object_collection.py | 49 +- .../test/assets/test_rigid_object.py | 20 +- .../assets/test_rigid_object_collection.py | 2 +- source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 12 + .../assets/articulation/articulation.py | 34 +- .../assets/rigid_object/rigid_object.py | 34 +- .../rigid_object_collection.py | 48 +- 23 files changed, 3761 insertions(+), 807 deletions(-) create mode 100644 source/isaaclab/test/utils/test_wrench_composer_integration.py create mode 100644 source/isaaclab/test/utils/test_wrench_composer_vs_physx.py diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 55b947608fd8..7092d52f3ff7 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.9" +version = "4.6.10" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 65768daf0b3c..0251fcb03ca2 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,44 @@ Changelog --------- +4.6.10 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :meth:`~isaaclab.utils.wrench_composer.WrenchComposer.add_raw_buffers_from` to merge one composer's raw + input buffers into another. + +Changed +^^^^^^^ + +* Refactored :class:`~isaaclab.utils.wrench_composer.WrenchComposer` to a dual-buffer architecture with separate + global (world-frame) and local (body-frame) buffers. A new + :meth:`~isaaclab.utils.wrench_composer.WrenchComposer.compose_to_body_frame` method rotates global forces/torques + into the body frame at apply time using the current body orientation, then sums with local forces/torques. + +Deprecated +^^^^^^^^^^ + +* Deprecated :attr:`~isaaclab.utils.wrench_composer.WrenchComposer.composed_force` and + :attr:`~isaaclab.utils.wrench_composer.WrenchComposer.composed_torque` in favor of + :attr:`~isaaclab.utils.wrench_composer.WrenchComposer.out_force_b` and + :attr:`~isaaclab.utils.wrench_composer.WrenchComposer.out_torque_b`. + +Fixed +^^^^^ + +* Fixed :class:`~isaaclab.utils.wrench_composer.WrenchComposer` not correctly updating the composed torque from global + positional forces when the body moves. +* Fixed :meth:`~isaaclab.utils.wrench_composer.WrenchComposer.reset` not clearing the ``_active`` flag when called + with ``slice(None)``. +* Fixed :class:`~isaaclab.utils.wrench_composer.WrenchComposer` producing spurious torque when global forces are + applied without explicit positions. +* Fixed ``set_external_force_and_torque`` wiping forces from non-resetting environments during partial + episode resets by using ``reset(env_ids)`` + ``add_forces_and_torques`` instead of ``set_forces_and_torques``. + + 4.6.9 (2026-04-22) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py index 25ca2c4ceaf0..14aa592ad103 100644 --- a/source/isaaclab/isaaclab/assets/articulation/base_articulation.py +++ b/source/isaaclab/isaaclab/assets/articulation/base_articulation.py @@ -2553,14 +2553,17 @@ def set_external_force_and_torque( env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, is_global: bool = False, ) -> None: - """Deprecated, same as :meth:`permanent_wrench_composer.set_forces_and_torques`.""" + """Deprecated. Resets target environments, then adds forces and torques via the permanent wrench composer.""" warnings.warn( - "The function 'set_external_force_and_torque' will be deprecated in a future release. Please" - " use 'permanent_wrench_composer.set_forces_and_torques' instead.", + "The function 'set_external_force_and_torque' is deprecated. Please use" + " 'permanent_wrench_composer.reset' followed by 'permanent_wrench_composer.add_forces_and_torques'" + " instead.", DeprecationWarning, stacklevel=2, ) - self.permanent_wrench_composer.set_forces_and_torques( + # Reset only target env_ids then add (not set which clears all envs globally) + self.permanent_wrench_composer.reset(env_ids=env_ids) + self.permanent_wrench_composer.add_forces_and_torques( forces, torques, positions=positions, body_ids=body_ids, env_ids=env_ids, is_global=is_global ) diff --git a/source/isaaclab/isaaclab/assets/rigid_object/base_rigid_object.py b/source/isaaclab/isaaclab/assets/rigid_object/base_rigid_object.py index a2e57aed5419..d7bc6ea4c85d 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object/base_rigid_object.py +++ b/source/isaaclab/isaaclab/assets/rigid_object/base_rigid_object.py @@ -845,13 +845,16 @@ def set_external_force_and_torque( env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, is_global: bool = False, ) -> None: - """Deprecated, same as :meth:`permanent_wrench_composer.set_forces_and_torques`.""" + """Deprecated. Resets target environments, then adds forces and torques via the permanent wrench composer.""" warnings.warn( - "The function 'set_external_force_and_torque' will be deprecated in a future release. Please" - " use 'permanent_wrench_composer.set_forces_and_torques' instead.", + "The function 'set_external_force_and_torque' is deprecated. Please use" + " 'permanent_wrench_composer.reset' followed by 'permanent_wrench_composer.add_forces_and_torques'" + " instead.", DeprecationWarning, stacklevel=2, ) - self.permanent_wrench_composer.set_forces_and_torques( + # Reset only target env_ids then add (not set which clears all envs globally) + self.permanent_wrench_composer.reset(env_ids=env_ids) + self.permanent_wrench_composer.add_forces_and_torques( forces, torques, positions=positions, body_ids=body_ids, env_ids=env_ids, is_global=is_global ) diff --git a/source/isaaclab/isaaclab/assets/rigid_object_collection/base_rigid_object_collection.py b/source/isaaclab/isaaclab/assets/rigid_object_collection/base_rigid_object_collection.py index 0384bc67da58..4be586145b0e 100644 --- a/source/isaaclab/isaaclab/assets/rigid_object_collection/base_rigid_object_collection.py +++ b/source/isaaclab/isaaclab/assets/rigid_object_collection/base_rigid_object_collection.py @@ -904,20 +904,18 @@ def set_external_force_and_torque( env_ids: Sequence[int] | torch.Tensor | wp.array | None = None, is_global: bool = False, ) -> None: - """Deprecated, same as :meth:`permanent_wrench_composer.set_forces_and_torques`.""" + """Deprecated. Resets target environments, then adds forces and torques via the permanent wrench composer.""" warnings.warn( - "The function 'set_external_force_and_torque' will be deprecated in a future release. Please" - " use 'permanent_wrench_composer.set_forces_and_torques' instead.", + "The function 'set_external_force_and_torque' is deprecated. Please use" + " 'permanent_wrench_composer.reset' followed by 'permanent_wrench_composer.add_forces_and_torques'" + " instead.", DeprecationWarning, stacklevel=2, ) - self.permanent_wrench_composer.set_forces_and_torques( - forces=forces, - torques=torques, - positions=positions, - body_ids=body_ids, - env_ids=env_ids, - is_global=is_global, + # Reset only target env_ids then add (not set which clears all envs globally) + self.permanent_wrench_composer.reset(env_ids=env_ids) + self.permanent_wrench_composer.add_forces_and_torques( + forces, torques, positions=positions, body_ids=body_ids, env_ids=env_ids, is_global=is_global ) def write_object_state_to_sim( diff --git a/source/isaaclab/isaaclab/test/mock_interfaces/utils/mock_wrench_composer.py b/source/isaaclab/isaaclab/test/mock_interfaces/utils/mock_wrench_composer.py index f35228ea6dcc..a20dbeb01274 100644 --- a/source/isaaclab/isaaclab/test/mock_interfaces/utils/mock_wrench_composer.py +++ b/source/isaaclab/isaaclab/test/mock_interfaces/utils/mock_wrench_composer.py @@ -11,6 +11,7 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING import torch @@ -21,14 +22,16 @@ class MockWrenchComposer: - """Mock WrenchComposer for testing. + """Mock WrenchComposer matching the dual-buffer API for testing. This class provides a mock implementation of WrenchComposer that matches the real interface but does not launch Warp kernels. It can be used for testing and benchmarking asset classes without requiring the full simulation environment. - The mock maintains simple buffers and sets the active flag when forces/torques are added, - but does not perform actual force composition computations. + The mock maintains the 5 input buffers and 2 output buffers matching the real WrenchComposer, + and sets the active flag when forces/torques are added. The ``compose_to_body_frame()`` method + simply copies the local buffers to the output buffers (since mock assets typically use identity + transforms). """ def __init__(self, asset: BaseArticulation | BaseRigidObject | BaseRigidObjectCollection) -> None: @@ -44,15 +47,23 @@ def __init__(self, asset: BaseArticulation | BaseRigidObject | BaseRigidObjectCo raise ValueError(f"Unsupported asset type: {asset.__class__.__name__}") self.device = asset.device self._asset = asset - self._active = False - # Create buffers using Warp (matching real WrenchComposer) - self._composed_force_b = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) - self._composed_torque_b = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) + # -- Tracking flags -- + self._active: bool = False + self._dirty: bool = False - # Create torch views (matching real WrenchComposer) - self._composed_force_b_torch = wp.to_torch(self._composed_force_b) - self._composed_torque_b_torch = wp.to_torch(self._composed_torque_b) + shape = (self.num_envs, self.num_bodies) + + # -- 5 input buffers -- + self._global_force_w = wp.zeros(shape, dtype=wp.vec3f, device=self.device) + self._global_torque_w = wp.zeros(shape, dtype=wp.vec3f, device=self.device) + self._global_force_at_com_w = wp.zeros(shape, dtype=wp.vec3f, device=self.device) + self._local_force_b = wp.zeros(shape, dtype=wp.vec3f, device=self.device) + self._local_torque_b = wp.zeros(shape, dtype=wp.vec3f, device=self.device) + + # -- 2 output buffers -- + self._out_force_b = wp.zeros(shape, dtype=wp.vec3f, device=self.device) + self._out_torque_b = wp.zeros(shape, dtype=wp.vec3f, device=self.device) # Create index arrays self._ALL_ENV_INDICES_WP = wp.from_torch( @@ -64,46 +75,145 @@ def __init__(self, asset: BaseArticulation | BaseRigidObject | BaseRigidObjectCo self._ALL_ENV_INDICES_TORCH = wp.to_torch(self._ALL_ENV_INDICES_WP) self._ALL_BODY_INDICES_TORCH = wp.to_torch(self._ALL_BODY_INDICES_WP) + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + @property def active(self) -> bool: - """Whether the wrench composer is active.""" + """Whether any forces or torques have been written since the last full reset.""" return self._active + # -- Input buffer accessors (read-only) -- + + @property + def global_force_w(self) -> wp.array: + """Positional global forces buffer. Shape ``(num_envs, num_bodies)``, dtype ``wp.vec3f``.""" + return self._global_force_w + + @property + def global_torque_w(self) -> wp.array: + """Global torques buffer (about world origin). Shape ``(num_envs, num_bodies)``, dtype ``wp.vec3f``.""" + return self._global_torque_w + + @property + def global_force_at_com_w(self) -> wp.array: + """Global forces at CoM buffer (no positional torque). Shape ``(num_envs, num_bodies)``, dtype ``wp.vec3f``.""" + return self._global_force_at_com_w + + @property + def local_force_b(self) -> wp.array: + """Body-frame forces buffer. Shape ``(num_envs, num_bodies)``, dtype ``wp.vec3f``.""" + return self._local_force_b + + @property + def local_torque_b(self) -> wp.array: + """Body-frame torques buffer. Shape ``(num_envs, num_bodies)``, dtype ``wp.vec3f``.""" + return self._local_torque_b + + # -- Output buffer accessors -- + + @property + def out_force_b(self) -> wp.array: + """Composed force in the body (link) frame. Shape ``(num_envs, num_bodies)``, dtype ``wp.vec3f``. + + Triggers composition from input buffers if dirty. + """ + self._ensure_composed() + return self._out_force_b + + @property + def out_torque_b(self) -> wp.array: + """Composed torque in the body (link) frame. Shape ``(num_envs, num_bodies)``, dtype ``wp.vec3f``. + + Triggers composition from input buffers if dirty. + """ + self._ensure_composed() + return self._out_torque_b + + # -- Legacy composed_force / composed_torque properties for backward compat -- + @property def composed_force(self) -> wp.array: """Composed force at the body's link frame. - Returns: - wp.array: Composed force at the body's link frame. (num_envs, num_bodies, 3) + .. deprecated:: 4.5.33 + Use :attr:`out_force_b` instead. """ - return self._composed_force_b + warnings.warn( + "The property 'composed_force' is deprecated. Use 'out_force_b' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.out_force_b @property def composed_torque(self) -> wp.array: """Composed torque at the body's link frame. - Returns: - wp.array: Composed torque at the body's link frame. (num_envs, num_bodies, 3) + .. deprecated:: 4.5.33 + Use :attr:`out_torque_b` instead. """ - return self._composed_torque_b + warnings.warn( + "The property 'composed_torque' is deprecated. Use 'out_torque_b' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.out_torque_b - @property - def composed_force_as_torch(self) -> torch.Tensor: - """Composed force at the body's link frame as torch tensor. + # ------------------------------------------------------------------ + # Composition + # ------------------------------------------------------------------ + + def compose_to_body_frame(self): + """Mock composition: sums all input buffers to output assuming identity transforms. - Returns: - torch.Tensor: Composed force at the body's link frame. (num_envs, num_bodies, 3) + Under identity transforms (no rotation), global-frame values equal body-frame values, + so all five input buffers are summed directly into the two output buffers. """ - return self._composed_force_b_torch + # Zero output buffers + self._out_force_b.zero_() + self._out_torque_b.zero_() - @property - def composed_torque_as_torch(self) -> torch.Tensor: - """Composed torque at the body's link frame as torch tensor. + # Use torch views for the accumulation + out_force_torch = wp.to_torch(self._out_force_b) + out_torque_torch = wp.to_torch(self._out_torque_b) + + # Sum all force contributions (identity: no rotation needed) + out_force_torch.add_(wp.to_torch(self._local_force_b)) + out_force_torch.add_(wp.to_torch(self._global_force_w)) + out_force_torch.add_(wp.to_torch(self._global_force_at_com_w)) - Returns: - torch.Tensor: Composed torque at the body's link frame. (num_envs, num_bodies, 3) + # Sum all torque contributions + out_torque_torch.add_(wp.to_torch(self._local_torque_b)) + out_torque_torch.add_(wp.to_torch(self._global_torque_w)) + + self._dirty = False + + # ------------------------------------------------------------------ + # Buffer merging + # ------------------------------------------------------------------ + + def add_raw_buffers_from(self, other: MockWrenchComposer): + """Element-wise add another composer's five input buffers into this one. + + Args: + other: Another :class:`MockWrenchComposer` whose input buffers will be added into this one. """ - return self._composed_torque_b_torch + # Use torch views for element-wise addition + wp.to_torch(self._global_force_w).add_(wp.to_torch(other._global_force_w)) + wp.to_torch(self._global_torque_w).add_(wp.to_torch(other._global_torque_w)) + wp.to_torch(self._global_force_at_com_w).add_(wp.to_torch(other._global_force_at_com_w)) + wp.to_torch(self._local_force_b).add_(wp.to_torch(other._local_force_b)) + wp.to_torch(self._local_torque_b).add_(wp.to_torch(other._local_torque_b)) + + if other._active: + self._active = True + self._dirty = True + + # ------------------------------------------------------------------ + # Add / Set methods + # ------------------------------------------------------------------ def add_forces_and_torques( self, @@ -172,9 +282,10 @@ def add_forces_and_torques_index( env_ids: torch.Tensor | None = None, is_global: bool = False, ) -> None: - """Add forces and torques by index (mock - just sets active flag).""" + """Add forces and torques by index (mock - sets active/dirty flags).""" if forces is not None or torques is not None: self._active = True + self._dirty = True def add_forces_and_torques_mask( self, @@ -185,9 +296,10 @@ def add_forces_and_torques_mask( env_mask: wp.array | torch.Tensor | None = None, is_global: bool = False, ) -> None: - """Add forces and torques by mask (mock - just sets active flag).""" + """Add forces and torques by mask (mock - sets active/dirty flags).""" if forces is not None or torques is not None: self._active = True + self._dirty = True def set_forces_and_torques_index( self, @@ -198,9 +310,10 @@ def set_forces_and_torques_index( env_ids: wp.array | torch.Tensor | None = None, is_global: bool = False, ) -> None: - """Set forces and torques by index (mock - just sets active flag).""" + """Set forces and torques by index (mock - sets active/dirty flags).""" if forces is not None or torques is not None: self._active = True + self._dirty = True def set_forces_and_torques_mask( self, @@ -211,28 +324,65 @@ def set_forces_and_torques_mask( env_mask: wp.array | torch.Tensor | None = None, is_global: bool = False, ) -> None: - """Set forces and torques by mask (mock - just sets active flag).""" + """Set forces and torques by mask (mock - sets active/dirty flags).""" if forces is not None or torques is not None: self._active = True + self._dirty = True + + # ------------------------------------------------------------------ + # Reset + # ------------------------------------------------------------------ def reset(self, env_ids: wp.array | torch.Tensor | None = None, env_mask: wp.array | None = None) -> None: - """Reset the composed force and torque. + """Reset all 7 buffers (5 input + 2 output) and clear all flags. Args: env_ids: Environment ids to reset. Defaults to None (all environments). env_mask: Environment mask to reset. Defaults to None (all environments). """ - if env_ids is None: - self._composed_force_b.zero_() - self._composed_torque_b.zero_() + if env_ids is None and env_mask is None: + # Full reset: zero all 7 buffers and clear flags + self._global_force_w.zero_() + self._global_torque_w.zero_() + self._global_force_at_com_w.zero_() + self._local_force_b.zero_() + self._local_torque_b.zero_() + self._out_force_b.zero_() + self._out_torque_b.zero_() self._active = False + self._dirty = False else: - # For partial reset, just zero the specified environments + # For partial reset, just zero the specified environments across all 7 buffers if isinstance(env_ids, torch.Tensor): indices = wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) elif isinstance(env_ids, list): indices = wp.array(env_ids, dtype=wp.int32, device=self.device) else: indices = env_ids - self._composed_force_b[indices].zero_() - self._composed_torque_b[indices].zero_() + + # Zero all 7 buffers for the specified environments + # Use torch views for the indexing operation + for buf in [ + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, + self._out_force_b, + self._out_torque_b, + ]: + buf_torch = wp.to_torch(buf) + if isinstance(env_ids, torch.Tensor): + buf_torch[env_ids.long()] = 0.0 + else: + idx_torch = wp.to_torch(indices).long() + buf_torch[idx_torch] = 0.0 + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _ensure_composed(self): + """Compose input buffers into output buffers if dirty.""" + if self._dirty: + self.compose_to_body_frame() diff --git a/source/isaaclab/isaaclab/utils/warp/kernels.py b/source/isaaclab/isaaclab/utils/warp/kernels.py index 2cc38b7d1996..efcdbfe63f1e 100644 --- a/source/isaaclab/isaaclab/utils/warp/kernels.py +++ b/source/isaaclab/isaaclab/utils/warp/kernels.py @@ -384,379 +384,324 @@ def reshape_tiled_image( ) ## -# Wrench Composer +# Wrench Composer — Dual-Buffer Architecture ## -@wp.func -def cast_to_link_frame(position: wp.vec3f, link_position: wp.vec3f, is_global: bool) -> wp.vec3f: - """Casts a position to the link frame of the body. - - Args: - position: The position to cast. - link_position: The link frame position. - is_global: Whether the position is in the global frame. - - Returns: - The position in the link frame of the body. - """ - if is_global: - return position - link_position - else: - return position - - -@wp.func -def cast_force_to_link_frame(force: wp.vec3f, link_quat: wp.quatf, is_global: bool) -> wp.vec3f: - """Casts a force to the link frame of the body. - - Args: - force: The force to cast. - link_quat: The link frame quaternion. - is_global: Whether the force is applied in the global frame. - Returns: - The force in the link frame of the body. - """ - if is_global: - return wp.quat_rotate_inv(link_quat, force) - else: - return force - - -@wp.func -def cast_torque_to_link_frame(torque: wp.vec3f, link_quat: wp.quatf, is_global: bool) -> wp.vec3f: - """Casts a torque to the link frame of the body. - - Args: - torque: The torque to cast. - link_quat: The link frame quaternion. - is_global: Whether the torque is applied in the global frame. - - Returns: - The torque in the link frame of the body. - """ - if is_global: - return wp.quat_rotate_inv(link_quat, torque) - else: - return torque - - @wp.kernel -def add_forces_and_torques_at_position_index( +def set_forces_to_dual_buffers_index( env_ids: wp.array(dtype=wp.int32), body_ids: wp.array(dtype=wp.int32), forces: wp.array2d(dtype=wp.vec3f), torques: wp.array2d(dtype=wp.vec3f), positions: wp.array2d(dtype=wp.vec3f), - link_poses: wp.array2d(dtype=wp.transformf), + global_force_w: wp.array2d(dtype=wp.vec3f), + global_torque_w: wp.array2d(dtype=wp.vec3f), + global_force_at_com_w: wp.array2d(dtype=wp.vec3f), + local_force_b: wp.array2d(dtype=wp.vec3f), + local_torque_b: wp.array2d(dtype=wp.vec3f), is_global: bool, - composed_forces_b: wp.array2d(dtype=wp.vec3f), - composed_torques_b: wp.array2d(dtype=wp.vec3f), ): - """Add forces and torques to the composed wrench at user-provided positions using index selection. + """Set forces/torques into dual buffers using index selection (overwrites). - When is_global is False, the user-provided positions offset the force application relative to - the link frame. When is_global is True, positions are in the global frame. Results are - accumulated (added) into the composed buffers. + Dispatched with ``dim=(len(env_ids), len(body_ids))``. - .. note:: - Expects partial data from the user (indexed by env_ids/body_ids). + When ``is_global`` is True, forces/torques are written to the world-frame buffers. + Forces with ``positions`` go to ``global_force_w`` with torque ``cross(P, F)`` accumulated + into ``global_torque_w``; forces without positions go to ``global_force_at_com_w``. + When ``is_global`` is False, values go to ``local_force_b`` / ``local_torque_b``. - Args: - env_ids: Input array of environment indices. Shape is (num_selected_envs,). - body_ids: Input array of body indices. Shape is (num_selected_bodies,). - forces: Input array of forces to apply. Shape is (num_selected_envs, num_selected_bodies). - Can be None if not provided. - torques: Input array of torques to apply. Shape is (num_selected_envs, num_selected_bodies). - Can be None if not provided. - positions: Input array of position offsets for force application. - Shape is (num_selected_envs, num_selected_bodies). Can be None if not provided. - link_poses: Input array of link frame poses in world frame. - Shape is (num_envs, num_bodies). - is_global: Input flag indicating whether forces/torques/positions are in the global frame. - composed_forces_b: Output array where forces in the link frame are accumulated. - Shape is (num_envs, num_bodies). - composed_torques_b: Output array where torques in the link frame are accumulated. - Shape is (num_envs, num_bodies). + Any of ``forces``, ``torques``, or ``positions`` may be ``None`` (null array). """ - # get the thread id tid_env, tid_body = wp.tid() + ei = env_ids[tid_env] + bi = body_ids[tid_body] - # add the forces to the composed force, if the positions are provided, also adds a torque to the composed torque. - if forces: - # add the forces to the composed force - composed_forces_b[env_ids[tid_env], body_ids[tid_body]] += cast_force_to_link_frame( - forces[tid_env, tid_body], - wp.transform_get_rotation(link_poses[env_ids[tid_env], body_ids[tid_body]]), - is_global, - ) - # if there is a position offset, add a torque to the composed torque. - if positions: - composed_torques_b[env_ids[tid_env], body_ids[tid_body]] += wp.skew( - cast_to_link_frame( - positions[tid_env, tid_body], - wp.transform_get_translation(link_poses[env_ids[tid_env], body_ids[tid_body]]), - is_global, - ) - ) @ cast_force_to_link_frame( - forces[tid_env, tid_body], - wp.transform_get_rotation(link_poses[env_ids[tid_env], body_ids[tid_body]]), - is_global, - ) - if torques: - composed_torques_b[env_ids[tid_env], body_ids[tid_body]] += cast_torque_to_link_frame( - torques[tid_env, tid_body], - wp.transform_get_rotation(link_poses[env_ids[tid_env], body_ids[tid_body]]), - is_global, - ) + if is_global: + if torques: + global_torque_w[ei, bi] = torques[tid_env, tid_body] + if forces: + if positions: + global_force_w[ei, bi] = forces[tid_env, tid_body] + if torques: + global_torque_w[ei, bi] = global_torque_w[ei, bi] + wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] + ) + else: + global_torque_w[ei, bi] = wp.cross(positions[tid_env, tid_body], forces[tid_env, tid_body]) + else: + global_force_at_com_w[ei, bi] = forces[tid_env, tid_body] + else: + if torques: + local_torque_b[ei, bi] = torques[tid_env, tid_body] + if forces: + local_force_b[ei, bi] = forces[tid_env, tid_body] + if positions: + if torques: + local_torque_b[ei, bi] = local_torque_b[ei, bi] + wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] + ) + else: + local_torque_b[ei, bi] = wp.cross(positions[tid_env, tid_body], forces[tid_env, tid_body]) @wp.kernel -def set_forces_and_torques_at_position_index( +def add_forces_to_dual_buffers_index( env_ids: wp.array(dtype=wp.int32), body_ids: wp.array(dtype=wp.int32), forces: wp.array2d(dtype=wp.vec3f), torques: wp.array2d(dtype=wp.vec3f), positions: wp.array2d(dtype=wp.vec3f), - link_poses: wp.array2d(dtype=wp.transformf), + global_force_w: wp.array2d(dtype=wp.vec3f), + global_torque_w: wp.array2d(dtype=wp.vec3f), + global_force_at_com_w: wp.array2d(dtype=wp.vec3f), + local_force_b: wp.array2d(dtype=wp.vec3f), + local_torque_b: wp.array2d(dtype=wp.vec3f), is_global: bool, - composed_forces_b: wp.array2d(dtype=wp.vec3f), - composed_torques_b: wp.array2d(dtype=wp.vec3f), ): - """Set forces and torques to the composed wrench at user-provided positions using index selection. + """Add forces/torques into dual buffers using index selection (accumulates). - When is_global is False, the user-provided positions offset the force application relative to - the link frame. When is_global is True, positions are in the global frame. Results are - overwritten (set) in the composed buffers. - - .. note:: - Expects partial data from the user (indexed by env_ids/body_ids). - - Args: - env_ids: Input array of environment indices. Shape is (num_selected_envs,). - body_ids: Input array of body indices. Shape is (num_selected_bodies,). - forces: Input array of forces to apply. Shape is (num_selected_envs, num_selected_bodies). - Can be None if not provided. - torques: Input array of torques to apply. Shape is (num_selected_envs, num_selected_bodies). - Can be None if not provided. - positions: Input array of position offsets for force application. - Shape is (num_selected_envs, num_selected_bodies). Can be None if not provided. - link_poses: Input array of link frame poses in world frame. - Shape is (num_envs, num_bodies). - is_global: Input flag indicating whether forces/torques/positions are in the global frame. - composed_forces_b: Output array where forces in the link frame are written. - Shape is (num_envs, num_bodies). - composed_torques_b: Output array where torques in the link frame are written. - Shape is (num_envs, num_bodies). + Same routing logic as :func:`set_forces_to_dual_buffers_index` but uses ``+=`` instead of ``=``. + Dispatched with ``dim=(len(env_ids), len(body_ids))``. """ - # get the thread id tid_env, tid_body = wp.tid() + ei = env_ids[tid_env] + bi = body_ids[tid_body] - # set the torques to the composed torque - if torques: - composed_torques_b[env_ids[tid_env], body_ids[tid_body]] = cast_torque_to_link_frame( - torques[tid_env, tid_body], - wp.transform_get_rotation(link_poses[env_ids[tid_env], body_ids[tid_body]]), - is_global, - ) - # set the forces to the composed force, if the positions are provided, adds a torque to the composed torque - # from the force at that position. - if forces: - # set the forces to the composed force - composed_forces_b[env_ids[tid_env], body_ids[tid_body]] = cast_force_to_link_frame( - forces[tid_env, tid_body], - wp.transform_get_rotation(link_poses[env_ids[tid_env], body_ids[tid_body]]), - is_global, - ) - # if there is a position offset, set the torque from the force at that position. - if positions: - composed_torques_b[env_ids[tid_env], body_ids[tid_body]] = wp.skew( - cast_to_link_frame( - positions[tid_env, tid_body], - wp.transform_get_translation(link_poses[env_ids[tid_env], body_ids[tid_body]]), - is_global, + if is_global: + if forces: + if positions: + global_force_w[ei, bi] = global_force_w[ei, bi] + forces[tid_env, tid_body] + global_torque_w[ei, bi] = global_torque_w[ei, bi] + wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] ) - ) @ cast_force_to_link_frame( - forces[tid_env, tid_body], - wp.transform_get_rotation(link_poses[env_ids[tid_env], body_ids[tid_body]]), - is_global, - ) + else: + global_force_at_com_w[ei, bi] = global_force_at_com_w[ei, bi] + forces[tid_env, tid_body] + if torques: + global_torque_w[ei, bi] = global_torque_w[ei, bi] + torques[tid_env, tid_body] + else: + if forces: + local_force_b[ei, bi] = local_force_b[ei, bi] + forces[tid_env, tid_body] + if positions: + local_torque_b[ei, bi] = local_torque_b[ei, bi] + wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] + ) + if torques: + local_torque_b[ei, bi] = local_torque_b[ei, bi] + torques[tid_env, tid_body] @wp.kernel -def add_forces_and_torques_at_position_mask( +def set_forces_to_dual_buffers_mask( env_mask: wp.array(dtype=wp.bool), body_mask: wp.array(dtype=wp.bool), forces: wp.array2d(dtype=wp.vec3f), torques: wp.array2d(dtype=wp.vec3f), positions: wp.array2d(dtype=wp.vec3f), - link_poses: wp.array2d(dtype=wp.transformf), + global_force_w: wp.array2d(dtype=wp.vec3f), + global_torque_w: wp.array2d(dtype=wp.vec3f), + global_force_at_com_w: wp.array2d(dtype=wp.vec3f), + local_force_b: wp.array2d(dtype=wp.vec3f), + local_torque_b: wp.array2d(dtype=wp.vec3f), is_global: bool, - composed_forces_b: wp.array2d(dtype=wp.vec3f), - composed_torques_b: wp.array2d(dtype=wp.vec3f), ): - """Add forces and torques to the composed wrench at user-provided positions using mask selection. - - When is_global is False, the user-provided positions offset the force application relative to - the link frame. When is_global is True, positions are in the global frame. Results are - accumulated (added) into the composed buffers. Only entries where both env_mask and body_mask - are True are processed. + """Set forces/torques into dual buffers using mask selection (overwrites). - .. note:: - Expects full data from the user (num_envs x num_bodies). - - Args: - env_mask: Input boolean mask for environments. Shape is (num_envs,). - body_mask: Input boolean mask for bodies. Shape is (num_bodies,). - forces: Input array of forces to apply. Shape is (num_envs, num_bodies). - Can be None if not provided. - torques: Input array of torques to apply. Shape is (num_envs, num_bodies). - Can be None if not provided. - positions: Input array of position offsets for force application. - Shape is (num_envs, num_bodies). Can be None if not provided. - link_poses: Input array of link frame poses in world frame. - Shape is (num_envs, num_bodies). - is_global: Input flag indicating whether forces/torques/positions are in the global frame. - composed_forces_b: Output array where forces in the link frame are accumulated. - Shape is (num_envs, num_bodies). - composed_torques_b: Output array where torques in the link frame are accumulated. - Shape is (num_envs, num_bodies). + Same routing logic as :func:`set_forces_to_dual_buffers_index` but threads are gated by + ``env_mask[tid_env] and body_mask[tid_body]``, and indices are direct (no indirection array). + Dispatched with ``dim=(num_envs, num_bodies)``. """ - # get the thread id tid_env, tid_body = wp.tid() if env_mask[tid_env] and body_mask[tid_body]: - # add the forces to the composed force, if the positions are provided, also adds a torque to the composed - # torque. - if forces: - # add the forces to the composed force - composed_forces_b[tid_env, tid_body] += cast_force_to_link_frame( - forces[tid_env, tid_body], wp.transform_get_rotation(link_poses[tid_env, tid_body]), is_global - ) - # if there is a position offset, add a torque to the composed torque. - if positions: - composed_torques_b[tid_env, tid_body] += wp.skew( - cast_to_link_frame( - positions[tid_env, tid_body], - wp.transform_get_translation(link_poses[tid_env, tid_body]), - is_global, - ) - ) @ cast_force_to_link_frame( - forces[tid_env, tid_body], wp.transform_get_rotation(link_poses[tid_env, tid_body]), is_global - ) - if torques: - composed_torques_b[tid_env, tid_body] += cast_torque_to_link_frame( - torques[tid_env, tid_body], wp.transform_get_rotation(link_poses[tid_env, tid_body]), is_global - ) + if is_global: + if torques: + global_torque_w[tid_env, tid_body] = torques[tid_env, tid_body] + if forces: + if positions: + global_force_w[tid_env, tid_body] = forces[tid_env, tid_body] + if torques: + global_torque_w[tid_env, tid_body] = global_torque_w[tid_env, tid_body] + wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] + ) + else: + global_torque_w[tid_env, tid_body] = wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] + ) + else: + global_force_at_com_w[tid_env, tid_body] = forces[tid_env, tid_body] + else: + if torques: + local_torque_b[tid_env, tid_body] = torques[tid_env, tid_body] + if forces: + local_force_b[tid_env, tid_body] = forces[tid_env, tid_body] + if positions: + if torques: + local_torque_b[tid_env, tid_body] = local_torque_b[tid_env, tid_body] + wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] + ) + else: + local_torque_b[tid_env, tid_body] = wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] + ) @wp.kernel -def set_forces_and_torques_at_position_mask( +def add_forces_to_dual_buffers_mask( env_mask: wp.array(dtype=wp.bool), body_mask: wp.array(dtype=wp.bool), forces: wp.array2d(dtype=wp.vec3f), torques: wp.array2d(dtype=wp.vec3f), positions: wp.array2d(dtype=wp.vec3f), - link_poses: wp.array2d(dtype=wp.transformf), + global_force_w: wp.array2d(dtype=wp.vec3f), + global_torque_w: wp.array2d(dtype=wp.vec3f), + global_force_at_com_w: wp.array2d(dtype=wp.vec3f), + local_force_b: wp.array2d(dtype=wp.vec3f), + local_torque_b: wp.array2d(dtype=wp.vec3f), is_global: bool, - composed_forces_b: wp.array2d(dtype=wp.vec3f), - composed_torques_b: wp.array2d(dtype=wp.vec3f), ): - """Set forces and torques to the composed wrench at user-provided positions using mask selection. + """Add forces/torques into dual buffers using mask selection (accumulates). - When is_global is False, the user-provided positions offset the force application relative to - the link frame. When is_global is True, positions are in the global frame. Results are - overwritten (set) in the composed buffers. Only entries where both env_mask and body_mask - are True are processed. - - .. note:: - Expects full data from the user (num_envs x num_bodies). - - Args: - env_mask: Input boolean mask for environments. Shape is (num_envs,). - body_mask: Input boolean mask for bodies. Shape is (num_bodies,). - forces: Input array of forces to apply. Shape is (num_envs, num_bodies). - Can be None if not provided. - torques: Input array of torques to apply. Shape is (num_envs, num_bodies). - Can be None if not provided. - positions: Input array of position offsets for force application. - Shape is (num_envs, num_bodies). Can be None if not provided. - link_poses: Input array of link frame poses in world frame. - Shape is (num_envs, num_bodies). - is_global: Input flag indicating whether forces/torques/positions are in the global frame. - composed_forces_b: Output array where forces in the link frame are written. - Shape is (num_envs, num_bodies). - composed_torques_b: Output array where torques in the link frame are written. - Shape is (num_envs, num_bodies). + Same routing logic as :func:`add_forces_to_dual_buffers_index` but threads are gated by + ``env_mask[tid_env] and body_mask[tid_body]``. + Dispatched with ``dim=(num_envs, num_bodies)``. """ - # get the thread id tid_env, tid_body = wp.tid() - # set the torques to the composed torque if env_mask[tid_env] and body_mask[tid_body]: - if torques: - composed_torques_b[tid_env, tid_body] = cast_torque_to_link_frame( - torques[tid_env, tid_body], wp.transform_get_rotation(link_poses[tid_env, tid_body]), is_global - ) - # set the forces to the composed force, if the positions are provided, adds a torque to the composed torque - # from the force at that position. - if forces: - # set the forces to the composed force - composed_forces_b[tid_env, tid_body] = cast_force_to_link_frame( - forces[tid_env, tid_body], wp.transform_get_rotation(link_poses[tid_env, tid_body]), is_global - ) - # if there is a position offset, set the torque from the force at that position. - if positions: - composed_torques_b[tid_env, tid_body] = wp.skew( - cast_to_link_frame( - positions[tid_env, tid_body], - wp.transform_get_translation(link_poses[tid_env, tid_body]), - is_global, + if is_global: + if forces: + if positions: + global_force_w[tid_env, tid_body] = global_force_w[tid_env, tid_body] + forces[tid_env, tid_body] + global_torque_w[tid_env, tid_body] = global_torque_w[tid_env, tid_body] + wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] ) - ) @ cast_force_to_link_frame( - forces[tid_env, tid_body], wp.transform_get_rotation(link_poses[tid_env, tid_body]), is_global - ) + else: + global_force_at_com_w[tid_env, tid_body] = ( + global_force_at_com_w[tid_env, tid_body] + forces[tid_env, tid_body] + ) + if torques: + global_torque_w[tid_env, tid_body] = global_torque_w[tid_env, tid_body] + torques[tid_env, tid_body] + else: + if forces: + local_force_b[tid_env, tid_body] = local_force_b[tid_env, tid_body] + forces[tid_env, tid_body] + if positions: + local_torque_b[tid_env, tid_body] = local_torque_b[tid_env, tid_body] + wp.cross( + positions[tid_env, tid_body], forces[tid_env, tid_body] + ) + if torques: + local_torque_b[tid_env, tid_body] = local_torque_b[tid_env, tid_body] + torques[tid_env, tid_body] @wp.kernel -def reset_wrench_composer_index( - env_ids: wp.array(dtype=wp.int32), - composed_forces_b: wp.array2d(dtype=wp.vec3f), - composed_torques_b: wp.array2d(dtype=wp.vec3f), +def add_raw_wrench_buffers( + src_gf: wp.array2d(dtype=wp.vec3f), + src_gt: wp.array2d(dtype=wp.vec3f), + src_gfc: wp.array2d(dtype=wp.vec3f), + src_lf: wp.array2d(dtype=wp.vec3f), + src_lt: wp.array2d(dtype=wp.vec3f), + dst_gf: wp.array2d(dtype=wp.vec3f), + dst_gt: wp.array2d(dtype=wp.vec3f), + dst_gfc: wp.array2d(dtype=wp.vec3f), + dst_lf: wp.array2d(dtype=wp.vec3f), + dst_lt: wp.array2d(dtype=wp.vec3f), ): - """Reset the composed force and torque to zero at the specified environment indices. + """Element-wise add all five source wrench buffers into destination buffers. - Args: - env_ids: Input array of environment indices to reset. Shape is (num_selected_envs,). - composed_forces_b: Output array where forces are zeroed. Shape is (num_envs, num_bodies). - composed_torques_b: Output array where torques are zeroed. Shape is (num_envs, num_bodies). + Dispatched with ``dim=(num_envs, num_bodies)``. Each ``src_*`` / ``dst_*`` pair corresponds + to one of the five input buffers (global_force_w, global_torque_w, global_force_at_com_w, + local_force_b, local_torque_b). """ + tid_env, tid_body = wp.tid() + dst_gf[tid_env, tid_body] = dst_gf[tid_env, tid_body] + src_gf[tid_env, tid_body] + dst_gt[tid_env, tid_body] = dst_gt[tid_env, tid_body] + src_gt[tid_env, tid_body] + dst_gfc[tid_env, tid_body] = dst_gfc[tid_env, tid_body] + src_gfc[tid_env, tid_body] + dst_lf[tid_env, tid_body] = dst_lf[tid_env, tid_body] + src_lf[tid_env, tid_body] + dst_lt[tid_env, tid_body] = dst_lt[tid_env, tid_body] + src_lt[tid_env, tid_body] - # get the thread id + +@wp.kernel +def compose_wrench_to_body_frame( + global_force_w: wp.array2d(dtype=wp.vec3f), + global_torque_w: wp.array2d(dtype=wp.vec3f), + global_force_at_com_w: wp.array2d(dtype=wp.vec3f), + local_force_b: wp.array2d(dtype=wp.vec3f), + local_torque_b: wp.array2d(dtype=wp.vec3f), + com_pos_w: wp.array2d(dtype=wp.vec3f), + link_quat_w: wp.array2d(dtype=wp.quatf), + out_force_b: wp.array2d(dtype=wp.vec3f), + out_torque_b: wp.array2d(dtype=wp.vec3f), +): + """Compose global and local wrench buffers into a single body-frame output. + + Global torques store the moment of positional forces about the world origin: ``cross(P, F)``. + This kernel corrects to be about the body's CoM via ``cross(P, F) - cross(com_pos_w, F) = + cross(P - com_pos_w, F)``, then rotates both force and torque into the body frame using + ``quat_rotate_inv(link_quat_w, ...)``, and adds local-frame values. + + Dispatched with ``dim=(num_envs, num_bodies)``. + """ tid_env, tid_body = wp.tid() + total_force_w = global_force_w[tid_env, tid_body] + global_force_at_com_w[tid_env, tid_body] + corrected_torque_w = global_torque_w[tid_env, tid_body] - wp.cross( + com_pos_w[tid_env, tid_body], global_force_w[tid_env, tid_body] + ) + out_force_b[tid_env, tid_body] = ( + wp.quat_rotate_inv(link_quat_w[tid_env, tid_body], total_force_w) + local_force_b[tid_env, tid_body] + ) + out_torque_b[tid_env, tid_body] = ( + wp.quat_rotate_inv(link_quat_w[tid_env, tid_body], corrected_torque_w) + local_torque_b[tid_env, tid_body] + ) + + +@wp.kernel +def reset_wrench_composer_index( + env_ids: wp.array(dtype=wp.int32), + global_force_w: wp.array2d(dtype=wp.vec3f), + global_torque_w: wp.array2d(dtype=wp.vec3f), + global_force_at_com_w: wp.array2d(dtype=wp.vec3f), + local_force_b: wp.array2d(dtype=wp.vec3f), + local_torque_b: wp.array2d(dtype=wp.vec3f), + out_force_b: wp.array2d(dtype=wp.vec3f), + out_torque_b: wp.array2d(dtype=wp.vec3f), +): + """Zero all 7 wrench composer buffers at the specified environment indices. - # reset the composed force and torque - composed_forces_b[env_ids[tid_env], tid_body] = wp.vec3f(0.0) - composed_torques_b[env_ids[tid_env], tid_body] = wp.vec3f(0.0) + Dispatched with ``dim=(len(env_ids), num_bodies)``. + """ + tid_env, tid_body = wp.tid() + ei = env_ids[tid_env] + z = wp.vec3f(0.0) + global_force_w[ei, tid_body] = z + global_torque_w[ei, tid_body] = z + global_force_at_com_w[ei, tid_body] = z + local_force_b[ei, tid_body] = z + local_torque_b[ei, tid_body] = z + out_force_b[ei, tid_body] = z + out_torque_b[ei, tid_body] = z @wp.kernel def reset_wrench_composer_mask( env_mask: wp.array(dtype=wp.bool), - composed_forces_b: wp.array2d(dtype=wp.vec3f), - composed_torques_b: wp.array2d(dtype=wp.vec3f), + global_force_w: wp.array2d(dtype=wp.vec3f), + global_torque_w: wp.array2d(dtype=wp.vec3f), + global_force_at_com_w: wp.array2d(dtype=wp.vec3f), + local_force_b: wp.array2d(dtype=wp.vec3f), + local_torque_b: wp.array2d(dtype=wp.vec3f), + out_force_b: wp.array2d(dtype=wp.vec3f), + out_torque_b: wp.array2d(dtype=wp.vec3f), ): - """Reset the composed force and torque to zero for environments matching the mask. + """Zero all 7 wrench composer buffers for environments matching the mask. - Args: - env_mask: Input boolean mask for environments. Shape is (num_envs,). - composed_forces_b: Output array where forces are zeroed. Shape is (num_envs, num_bodies). - composed_torques_b: Output array where torques are zeroed. Shape is (num_envs, num_bodies). + Dispatched with ``dim=(num_envs, num_bodies)``. """ - # get the thread id tid_env, tid_body = wp.tid() - - # reset the composed force and torque if env_mask[tid_env]: - composed_forces_b[tid_env, tid_body] = wp.vec3f(0.0) - composed_torques_b[tid_env, tid_body] = wp.vec3f(0.0) + z = wp.vec3f(0.0) + global_force_w[tid_env, tid_body] = z + global_torque_w[tid_env, tid_body] = z + global_force_at_com_w[tid_env, tid_body] = z + local_force_b[tid_env, tid_body] = z + local_torque_b[tid_env, tid_body] = z + out_force_b[tid_env, tid_body] = z + out_torque_b[tid_env, tid_body] = z diff --git a/source/isaaclab/isaaclab/utils/wrench_composer.py b/source/isaaclab/isaaclab/utils/wrench_composer.py index 5ad966a6e4e9..e348697306d5 100644 --- a/source/isaaclab/isaaclab/utils/wrench_composer.py +++ b/source/isaaclab/isaaclab/utils/wrench_composer.py @@ -6,6 +6,7 @@ from __future__ import annotations import warnings +from collections.abc import Sequence from typing import TYPE_CHECKING import numpy as np @@ -13,12 +14,14 @@ import warp as wp from isaaclab.utils.warp.kernels import ( - add_forces_and_torques_at_position_index, - add_forces_and_torques_at_position_mask, + add_forces_to_dual_buffers_index, + add_forces_to_dual_buffers_mask, + add_raw_wrench_buffers, + compose_wrench_to_body_frame, reset_wrench_composer_index, reset_wrench_composer_mask, - set_forces_and_torques_at_position_index, - set_forces_and_torques_at_position_mask, + set_forces_to_dual_buffers_index, + set_forces_to_dual_buffers_mask, ) if TYPE_CHECKING: @@ -27,16 +30,33 @@ class WrenchComposer: def __init__(self, asset: BaseArticulation | BaseRigidObject | BaseRigidObjectCollection) -> None: - """Wrench composer. + """Wrench composer with dual-buffer architecture. - This class is used to compose forces and torques at the body's link frame. - It can compose global wrenches and local wrenches. The result is always in the link frame of the body. + This class composes forces and torques applied to rigid bodies. Forces and torques can be + specified in either the global (world) frame or the local (body) frame. Internally, they are + stored in separate global and local input buffers. When the final composed wrench is needed, + the global contributions are rotated into the body frame and combined with the local + contributions to produce the output force and torque expressed in the body frame. + + The dual-buffer architecture uses five input buffers: + + - ``global_force_w``: Global forces [N] (world frame). + - ``global_torque_w``: Global torques [N·m] (world frame), including moment contributions + from positional forces (``cross(P, F)``). + - ``global_force_at_com_w``: Global forces [N] applied at the body's CoM (world frame, no positional torque). + - ``local_force_b``: Local forces [N] (body frame). + - ``local_torque_b``: Local torques [N·m] (body frame). + + And two output buffers: + + - ``out_force_b``: Composed force [N] in body frame. + - ``out_torque_b``: Composed torque [N·m] in body frame. Args: - asset: Asset to use. Defaults to None. + asset: Asset to use. """ self.num_envs = asset.num_instances - # Avoid isinstance to prevent circular import issues, use attribute presence instead. + # Avoid isinstance to prevent circular import issues; check by attribute presence instead. if hasattr(asset, "num_bodies"): self.num_bodies = asset.num_bodies else: @@ -44,16 +64,28 @@ def __init__(self, asset: BaseArticulation | BaseRigidObject | BaseRigidObjectCo self.device = asset.device self._asset = asset self._active = False - - # Avoid isinstance here due to potential circular import issues; check by attribute presence instead. - if hasattr(self._asset.data, "body_link_pose_w"): - self._get_link_pose_fn = lambda a=self._asset: a.data.body_link_pose_w + self._dirty = False + if hasattr(self._asset.data, "body_com_pos_w"): + self._get_com_pos_fn = lambda a=self._asset: a.data.body_com_pos_w else: raise ValueError(f"Unsupported asset type: {self._asset.__class__.__name__}") + if hasattr(self._asset.data, "body_link_quat_w"): + self._get_link_quat_fn = lambda a=self._asset: a.data.body_link_quat_w + else: + raise ValueError(f"Unsupported asset type: {self._asset.__class__.__name__}") + + # -- Input buffers (5 total) -- + self._global_force_w = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) + self._global_torque_w = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) + self._global_force_at_com_w = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) + self._local_force_b = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) + self._local_torque_b = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) + + # -- Output buffers (2 total) -- + self._out_force_b = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) + self._out_torque_b = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) - # Create buffers - self._composed_force_b = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) - self._composed_torque_b = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) + # -- Index / mask helper arrays -- self._ALL_ENV_INDICES = wp.array(np.arange(self.num_envs, dtype=np.int32), dtype=wp.int32, device=self.device) self._ALL_BODY_INDICES = wp.array( np.arange(self.num_bodies, dtype=np.int32), dtype=wp.int32, device=self.device @@ -61,44 +93,125 @@ def __init__(self, asset: BaseArticulation | BaseRigidObject | BaseRigidObjectCo self._ALL_ENV_MASK = wp.ones((self.num_envs), dtype=wp.bool, device=self.device) self._ALL_BODY_MASK = wp.ones((self.num_bodies), dtype=wp.bool, device=self.device) - # Temporary buffers for the masks, positions, and forces/torques (reused to avoid allocations) - self._temp_env_mask_wp = wp.zeros((self.num_envs,), dtype=wp.bool, device=self.device) - self._temp_body_mask_wp = wp.zeros((self.num_bodies,), dtype=wp.bool, device=self.device) - self._temp_positions_wp = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) - self._temp_forces_wp = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) - self._temp_torques_wp = wp.zeros((self.num_envs, self.num_bodies), dtype=wp.vec3f, device=self.device) - - # Flag to check if the link poses have been updated. - self._link_poses_updated = False + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ @property def active(self) -> bool: - """Whether the wrench composer is active.""" + """Whether the wrench composer is active (has pending forces/torques). + + Set to ``True`` when any ``add_*`` or ``set_*`` method writes data. Cleared only by a + full :meth:`reset` call (no arguments). Partial resets (with ``env_ids`` or ``env_mask``) + do **not** clear this flag because checking whether all environments are zero would + require scanning the buffers, defeating the purpose of a cheap guard. + + This means the flag may remain ``True`` even if all buffers are zero after partial resets. + This is by design: the cost of an unnecessary compose + apply on zero data is negligible + compared to scanning the buffers every frame. + """ return self._active @property - def composed_force(self) -> wp.array: - """Composed force at the body's link frame. + def global_force_w(self) -> wp.array: + """Global force buffer [N] (world frame), dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. - .. note:: If some of the forces are applied in the global frame, the composed force will be in the link frame - of the body. + .. note:: + This returns the underlying buffer reference for read-only inspection. Writing to it + directly bypasses the dirty flag and may produce stale output buffers. Use the + ``add_*`` or ``set_*`` methods to modify forces. + """ + return self._global_force_w - Returns: - wp.array: Composed force at the body's link frame. (num_envs, num_bodies, 3) + @property + def global_torque_w(self) -> wp.array: + """Global torque buffer [N·m] (world frame), dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. + + Stores user-supplied torques plus moment contributions from positional forces (``cross(P, F)``). + + .. note:: + Read-only reference. See :attr:`global_force_w` for caveats on direct writes. """ - return self._composed_force_b + return self._global_torque_w @property - def composed_torque(self) -> wp.array: - """Composed torque at the body's link frame. + def global_force_at_com_w(self) -> wp.array: + """Global force at body's CoM buffer [N] (world frame, no positional torque). - .. note:: If some of the torques are applied in the global frame, the composed torque will be in the link frame - of the body. + dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. - Returns: - wp.array: Composed torque at the body's link frame. (num_envs, num_bodies, 3) + .. note:: + Read-only reference. See :attr:`global_force_w` for caveats on direct writes. """ - return self._composed_torque_b + return self._global_force_at_com_w + + @property + def local_force_b(self) -> wp.array: + """Local force buffer [N] (body frame), dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. + + .. note:: + Read-only reference. See :attr:`global_force_w` for caveats on direct writes. + """ + return self._local_force_b + + @property + def local_torque_b(self) -> wp.array: + """Local torque buffer [N·m] (body frame), dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. + + .. note:: + Read-only reference. See :attr:`global_force_w` for caveats on direct writes. + """ + return self._local_torque_b + + @property + def out_force_b(self) -> wp.array: + """Composed output force [N] in the body frame, dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. + + Triggers composition from input buffers if dirty. + """ + self._ensure_composed() + return self._out_force_b + + @property + def out_torque_b(self) -> wp.array: + """Composed output torque [N·m] in the body frame, dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. + + Triggers composition from input buffers if dirty. + """ + self._ensure_composed() + return self._out_torque_b + + @property + def composed_force(self) -> wp.array: + """Composed force at the body frame, dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. + + .. deprecated:: 4.5.33 + Use :attr:`out_force_b` instead. + """ + warnings.warn( + "The property 'composed_force' is deprecated. Use 'out_force_b' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.out_force_b + + @property + def composed_torque(self) -> wp.array: + """Composed torque at the body frame, dtype ``wp.vec3f``. Shape: ``(num_envs, num_bodies)``. + + .. deprecated:: 4.5.33 + Use :attr:`out_torque_b` instead. + """ + warnings.warn( + "The property 'composed_torque' is deprecated. Use 'out_torque_b' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.out_torque_b + + # ------------------------------------------------------------------ + # Public methods + # ------------------------------------------------------------------ def add_forces_and_torques_index( self, @@ -109,38 +222,26 @@ def add_forces_and_torques_index( env_ids: torch.Tensor | None = None, is_global: bool = False, ): - """Add forces and torques to the composed force and torque. - - Composed force and torque are the sum of all the forces and torques applied to the body. - It can compose global wrenches and local wrenches. The result is always in the link frame of the body. + """Add forces and torques into the input buffers using index-based selection. - The user can provide any combination of forces, torques, and positions. - - .. note:: Users may want to call `reset` function after every simulation step to ensure no force is carried - over to the next step. However, this may not necessary if the user calls `set_forces_and_torques` function - instead of `add_forces_and_torques`. + Accumulates onto whatever is already in the buffers. The result is always composed into the + body frame when the output properties are accessed. Args: - forces: Forces. (len(env_ids), len(body_ids), 3). Defaults to None. - torques: Torques. (len(env_ids), len(body_ids), 3). Defaults to None. - positions: Positions. (len(env_ids), len(body_ids), 3). Defaults to None. - body_ids: Body ids. Defaults to None (all bodies). - env_ids: Environment ids. Defaults to None (all environments). - is_global: Whether the forces and torques are applied in the global frame. Defaults to False. - - Raises: - ValueError: If the type of the input is not supported. - ValueError: If the input is a slice and it is not None. + forces: Forces [N]. Shape: (len(env_ids), len(body_ids), 3). Defaults to None. + torques: Torques [N·m]. Shape: (len(env_ids), len(body_ids), 3). Defaults to None. + positions: The positions [m] at which forces act. If `is_global` is True, these are global + positions expressed in the world frame. If `is_global` is False, these are offsets from the + body's CoM expressed in the body frame. If None, forces are assumed to act at the body's + CoM, independent of the `is_global` flag. + Shape: (len(env_ids), len(body_ids), 3). Defaults to None. + body_ids: Body indices. Defaults to None (all bodies). + env_ids: Environment indices. Defaults to None (all environments). + is_global: Whether the forces and torques are expressed in the global world frame or the local body frame. + Defaults to False. """ - # Resolve all indices - if (env_ids is None) or (env_ids == slice(None)): - env_ids = self._ALL_ENV_INDICES - if isinstance(env_ids, list): - env_ids = wp.array(env_ids, dtype=wp.int32, device=self.device) - if (body_ids is None) or (body_ids == slice(None)): - body_ids = self._ALL_BODY_INDICES - if isinstance(body_ids, list): - body_ids = wp.array(body_ids, dtype=wp.int32, device=self.device) + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) if forces is None and torques is None: warnings.warn( "No forces or torques provided. No force will be added.", @@ -148,16 +249,12 @@ def add_forces_and_torques_index( stacklevel=2, ) return - # Get the link poses - if not self._link_poses_updated: - self._link_poses = self._get_link_pose_fn() - self._link_poses_updated = True - # Set the active flag to true self._active = True + self._dirty = True wp.launch( - add_forces_and_torques_at_position_index, + add_forces_to_dual_buffers_index, dim=(env_ids.shape[0], body_ids.shape[0]), inputs=[ env_ids, @@ -165,13 +262,13 @@ def add_forces_and_torques_index( forces, torques, positions, - self._link_poses, + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, is_global, ], - outputs=[ - self._composed_force_b, - self._composed_torque_b, - ], device=self.device, ) @@ -184,51 +281,43 @@ def set_forces_and_torques_index( env_ids: wp.array | torch.Tensor | None = None, is_global: bool = False, ): - """Set forces and torques to the composed force and torque. - - Composed force and torque are the sum of all the forces and torques applied to the body. - It can compose global wrenches and local wrenches. The result is always in the link frame of the body. + """Set forces and torques into the input buffers using index-based selection. - The user can provide any combination of forces, torques, and positions. + Resets the specified environments first, then writes the new values. This replaces any + previously accumulated forces/torques for the targeted environments while leaving other + environments untouched. Args: - forces: Forces. (num_envs, num_bodies, 3). Defaults to None. - torques: Torques. (num_envs, num_bodies, 3). Defaults to None. - positions: Positions. (num_envs, num_bodies, 3). Defaults to None. - body_ids: Body ids. (num_envs, num_bodies). Defaults to None (all bodies). - env_ids: Environment ids. (num_envs). Defaults to None (all environments). - is_global: Whether the forces and torques are applied in the global frame. Defaults to False. - - Raises: - ValueError: If the type of the input is not supported. - ValueError: If the input is a slice and it is not None. + forces: Forces [N]. Shape: (len(env_ids), len(body_ids), 3). Defaults to None. + torques: Torques [N·m]. Shape: (len(env_ids), len(body_ids), 3). Defaults to None. + positions: The positions [m] at which forces act. If `is_global` is True, these are global + positions expressed in the world frame. If `is_global` is False, these are offsets from the + body's CoM expressed in the body frame. If None, forces are assumed to act at the body's + CoM, independent of the `is_global` flag. + Shape: (len(env_ids), len(body_ids), 3). Defaults to None. + body_ids: Body indices. Defaults to None (all bodies). + env_ids: Environment indices. Defaults to None (all environments). + is_global: Whether the forces and torques are expressed in the global world frame or the local body frame. + Defaults to False. """ - # Resolve all indices - if (env_ids is None) or (env_ids == slice(None)): - env_ids = self._ALL_ENV_INDICES - if isinstance(env_ids, list): - env_ids = wp.array(env_ids, dtype=wp.int32, device=self.device) - if (body_ids is None) or (body_ids == slice(None)): - body_ids = self._ALL_BODY_INDICES - if isinstance(body_ids, list): - body_ids = wp.array(body_ids, dtype=wp.int32, device=self.device) + env_ids = self._resolve_env_ids(env_ids) + body_ids = self._resolve_body_ids(body_ids) if forces is None and torques is None: warnings.warn( - "No forces or torques provided. No force will be added.", + "No forces or torques provided. No force will be set.", UserWarning, stacklevel=2, ) return - # Get the link poses - if not self._link_poses_updated: - self._link_poses = self._get_link_pose_fn() - self._link_poses_updated = True - # Set the active flag to true + # Clear input buffers for the targeted environments before writing + self.reset(env_ids=env_ids) + self._active = True + self._dirty = True wp.launch( - set_forces_and_torques_at_position_index, + set_forces_to_dual_buffers_index, dim=(env_ids.shape[0], body_ids.shape[0]), inputs=[ env_ids, @@ -236,13 +325,13 @@ def set_forces_and_torques_index( forces, torques, positions, - self._link_poses, + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, is_global, ], - outputs=[ - self._composed_force_b, - self._composed_torque_b, - ], device=self.device, ) @@ -255,30 +344,23 @@ def add_forces_and_torques_mask( env_mask: wp.array | torch.Tensor | None = None, is_global: bool = False, ): - """Add forces and torques to the composed force and torque. - - Composed force and torque are the sum of all the forces and torques applied to the body. - It can compose global wrenches and local wrenches. The result is always in the link frame of the body. + """Add forces and torques into the input buffers using mask-based selection. - The user can provide any combination of forces, torques, and positions. - - .. note:: Users may want to call `reset` function after every simulation step to ensure no force is carried - over to the next step. However, this may not necessary if the user calls `set_forces_and_torques` function - instead of `add_forces_and_torques`. + Accumulates onto whatever is already in the buffers. Args: - forces: Forces. (num_envs, num_bodies, 3). Defaults to None. - torques: Torques. (num_envs, num_bodies, 3). Defaults to None. - positions: Positions. (num_envs, num_bodies, 3). Defaults to None. - body_mask: Body mask. (num_bodies). Defaults to None (all bodies). - env_mask: Environment mask. (num_envs). Defaults to None (all environments). - is_global: Whether the forces and torques are applied in the global frame. Defaults to False. - - Raises: - ValueError: If the type of the input is not supported. - ValueError: If the input is a slice and it is not None. + forces: Forces [N]. Shape: (num_envs, num_bodies, 3). Defaults to None. + torques: Torques [N·m]. Shape: (num_envs, num_bodies, 3). Defaults to None. + positions: The positions [m] at which forces act. If `is_global` is True, these are global + positions expressed in the world frame. If `is_global` is False, these are offsets from the + body's CoM expressed in the body frame. If None, forces are assumed to act at the body's + CoM, independent of the `is_global` flag. + Shape: (num_envs, num_bodies, 3). Defaults to None. + body_mask: Body mask. Shape: (num_bodies,). Defaults to None (all bodies). + env_mask: Environment mask. Shape: (num_envs,). Defaults to None (all environments). + is_global: Whether the forces and torques are expressed in the global world frame or the local body frame. + Defaults to False. """ - # Resolve all indices if env_mask is None: env_mask = self._ALL_ENV_MASK if body_mask is None: @@ -290,16 +372,12 @@ def add_forces_and_torques_mask( stacklevel=2, ) return - # Get the link poses - if not self._link_poses_updated: - self._link_poses = self._get_link_pose_fn() - self._link_poses_updated = True - # Set the active flag to true self._active = True + self._dirty = True wp.launch( - add_forces_and_torques_at_position_mask, + add_forces_to_dual_buffers_mask, dim=(self.num_envs, self.num_bodies), inputs=[ env_mask, @@ -307,13 +385,13 @@ def add_forces_and_torques_mask( forces, torques, positions, - self._link_poses, + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, is_global, ], - outputs=[ - self._composed_force_b, - self._composed_torque_b, - ], device=self.device, ) @@ -326,47 +404,45 @@ def set_forces_and_torques_mask( env_mask: wp.array | torch.Tensor | None = None, is_global: bool = False, ): - """Set forces and torques to the composed force and torque. + """Set forces and torques into the input buffers using mask-based selection. - Composed force and torque are the sum of all the forces and torques applied to the body. - It can compose global wrenches and local wrenches. The result is always in the link frame of the body. - - The user can provide any combination of forces, torques, and positions. + Resets the masked environments first, then writes the new values. This replaces any + previously accumulated forces/torques for the masked environments while leaving other + environments untouched. Args: - forces: Forces. (num_envs, num_bodies, 3). Defaults to None. - torques: Torques. (num_envs, num_bodies, 3). Defaults to None. - positions: Positions. (num_envs, num_bodies, 3). Defaults to None. - body_mask: Body mask. (num_bodies). Defaults to None (all bodies). - env_mask: Environment mask. (num_envs). Defaults to None (all environments). - is_global: Whether the forces and torques are applied in the global frame. Defaults to False. - - Raises: - ValueError: If the type of the input is not supported. - ValueError: If the input is a slice and it is not None. + forces: Forces [N]. Shape: (num_envs, num_bodies, 3). Defaults to None. + torques: Torques [N·m]. Shape: (num_envs, num_bodies, 3). Defaults to None. + positions: The positions [m] at which forces act. If `is_global` is True, these are global + positions expressed in the world frame. If `is_global` is False, these are offsets from the + body's CoM expressed in the body frame. If None, forces are assumed to act at the body's + CoM, independent of the `is_global` flag. + Shape: (num_envs, num_bodies, 3). Defaults to None. + body_mask: Body mask. Shape: (num_bodies,). Defaults to None (all bodies). + env_mask: Environment mask. Shape: (num_envs,). Defaults to None (all environments). + is_global: Whether the forces and torques are expressed in the global world frame or the local body frame. + Defaults to False. """ - # Resolve all indices if env_mask is None: env_mask = self._ALL_ENV_MASK if body_mask is None: body_mask = self._ALL_BODY_MASK if forces is None and torques is None: warnings.warn( - "No forces or torques provided. No force will be added.", + "No forces or torques provided. No force will be set.", UserWarning, stacklevel=2, ) return - # Get the link poses - if not self._link_poses_updated: - self._link_poses = self._get_link_pose_fn() - self._link_poses_updated = True - # Set the active flag to true + # Clear input buffers for the masked environments before writing + self.reset(env_mask=env_mask) + self._active = True + self._dirty = True wp.launch( - set_forces_and_torques_at_position_mask, + set_forces_to_dual_buffers_mask, dim=(self.num_envs, self.num_bodies), inputs=[ env_mask, @@ -374,74 +450,159 @@ def set_forces_and_torques_mask( forces, torques, positions, - self._link_poses, + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, is_global, ], - outputs=[ - self._composed_force_b, - self._composed_torque_b, + device=self.device, + ) + + def add_raw_buffers_from(self, other: WrenchComposer): + """Add another composer's raw input buffers into this composer's input buffers. + + This performs element-wise addition of all five input buffers from ``other`` into ``self``. + Useful for combining wrenches from multiple sources before composition. + + Args: + other: Another WrenchComposer whose input buffers will be added into this one. + """ + if not other._active: + return + if __debug__: + if other.num_envs != self.num_envs or other.num_bodies != self.num_bodies: + raise ValueError( + f"Cannot add buffers from composer with shape ({other.num_envs}, {other.num_bodies}) " + f"into composer with shape ({self.num_envs}, {self.num_bodies})." + ) + + self._active = True + self._dirty = True + + wp.launch( + add_raw_wrench_buffers, + dim=(self.num_envs, self.num_bodies), + inputs=[ + other._global_force_w, + other._global_torque_w, + other._global_force_at_com_w, + other._local_force_b, + other._local_torque_b, + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, ], device=self.device, ) - def reset(self, env_ids: wp.array | torch.Tensor | None = None, env_mask: wp.array | None = None): - """Reset the composed force and torque. + def compose_to_body_frame(self): + """Compose the five input buffers into the two output buffers in body frame. - This function will reset the composed force and torque to zero. - It will also make sure the link positions and quaternions are updated in the next call of the - `add_forces_and_torques` or `set_forces_and_torques` functions. + This corrects world-frame torques for the body's CoM position, rotates global forces and torques into the + body frame, then adds local-frame contributions. After this call, ``out_force_b`` and ``out_torque_b`` + contain the final composed wrench. - .. note:: This function should be called after every simulation step / reset to ensure no force is carried - over to the next step. + The dirty flag is cleared after composition. + """ + com_pos_w = self._get_com_pos_fn() + link_quat_w = self._get_link_quat_fn() - .. caution:: If both :attr:`env_ids` and :attr:`env_mask` are provided, then :attr:`env_mask` takes precedence - over :attr:`env_ids`. + wp.launch( + compose_wrench_to_body_frame, + dim=(self.num_envs, self.num_bodies), + inputs=[ + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, + com_pos_w, + link_quat_w, + self._out_force_b, + self._out_torque_b, + ], + device=self.device, + ) + self._dirty = False + + def reset( + self, + env_ids: wp.array | torch.Tensor | Sequence[int] | slice | None = None, + env_mask: wp.array | None = None, + ): + """Reset the wrench composer buffers. + + With no arguments, zeros all seven buffers (5 input + 2 output) and clears all flags. + With ``env_ids`` or ``env_mask``, performs a partial reset on the specified environments + using the reset kernels. + + .. caution:: If both ``env_ids`` and ``env_mask`` are provided, ``env_mask`` takes precedence. Args: env_ids: Environment indices. Defaults to None (all environments). env_mask: Environment mask. Defaults to None (all environments). """ if env_ids is None and env_mask is None: - self._composed_force_b.zero_() - self._composed_torque_b.zero_() + # Full reset: zero all 7 buffers + self._global_force_w.zero_() + self._global_torque_w.zero_() + self._global_force_at_com_w.zero_() + self._local_force_b.zero_() + self._local_torque_b.zero_() + self._out_force_b.zero_() + self._out_torque_b.zero_() self._active = False + self._dirty = False elif env_mask is not None: wp.launch( reset_wrench_composer_mask, dim=(self.num_envs, self.num_bodies), inputs=[ env_mask, - ], - outputs=[ - self._composed_force_b, - self._composed_torque_b, + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, + self._out_force_b, + self._out_torque_b, ], device=self.device, ) + self._dirty = True else: + # Partial reset via index if env_ids is None or env_ids == slice(None): env_ids = self._ALL_ENV_INDICES elif isinstance(env_ids, list): env_ids = wp.array(env_ids, dtype=wp.int32, device=self.device) elif isinstance(env_ids, torch.Tensor): env_ids = wp.from_torch(env_ids.to(torch.int32), dtype=wp.int32) + wp.launch( reset_wrench_composer_index, dim=(env_ids.shape[0], self.num_bodies), inputs=[ env_ids, - ], - outputs=[ - self._composed_force_b, - self._composed_torque_b, + self._global_force_w, + self._global_torque_w, + self._global_force_at_com_w, + self._local_force_b, + self._local_torque_b, + self._out_force_b, + self._out_torque_b, ], device=self.device, ) - self._link_poses_updated = False + self._dirty = True - """ - Deprecated functions. - """ + # ------------------------------------------------------------------ + # Deprecated methods + # ------------------------------------------------------------------ def add_forces_and_torques( self, @@ -452,10 +613,13 @@ def add_forces_and_torques( env_ids: torch.Tensor | None = None, is_global: bool = False, ): - """Deprecated, same as :meth:`add_forces_and_torques_index`.""" + """Deprecated, same as :meth:`add_forces_and_torques_index`. + + .. deprecated:: 4.5.33 + Use :meth:`add_forces_and_torques_index` instead. + """ warnings.warn( - "The function 'add_forces_and_torques' will be deprecated in a future release. Please" - " use 'add_forces_and_torques_index' instead.", + "The function 'add_forces_and_torques' is deprecated. Please use 'add_forces_and_torques_index' instead.", DeprecationWarning, stacklevel=2, ) @@ -470,11 +634,80 @@ def set_forces_and_torques( env_ids: wp.array | torch.Tensor | None = None, is_global: bool = False, ): - """Deprecated, same as :meth:`set_forces_and_torques_index`.""" + """Deprecated, same as :meth:`set_forces_and_torques_index`. + + .. deprecated:: 4.5.33 + Use :meth:`set_forces_and_torques_index` instead. + """ warnings.warn( - "The function 'set_forces_and_torques' will be deprecated in a future release. Please" - " use 'set_forces_and_torques_index' instead.", + "The function 'set_forces_and_torques' is deprecated. Please use 'set_forces_and_torques_index' instead.", DeprecationWarning, stacklevel=2, ) self.set_forces_and_torques_index(forces, torques, positions, body_ids, env_ids, is_global) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _resolve_env_ids(self, env_ids: wp.array | torch.Tensor | list | slice | None) -> wp.array: + """Resolve environment IDs to a warp int32 array. + + Args: + env_ids: Environment indices as any supported type, or None for all environments. + + Returns: + Warp array of int32 environment indices. + + Raises: + TypeError: If ``env_ids`` is an unsupported type. + """ + if env_ids is None: + return self._ALL_ENV_INDICES + # Check tensor types before slice comparison (tensor == slice crashes) + if isinstance(env_ids, torch.Tensor): + if env_ids.dtype == torch.int64: + env_ids = env_ids.to(torch.int32) + return wp.from_torch(env_ids.contiguous(), dtype=wp.int32) + if isinstance(env_ids, wp.array): + return env_ids + if env_ids == slice(None): + return self._ALL_ENV_INDICES + if isinstance(env_ids, list): + return wp.array(env_ids, dtype=wp.int32, device=self.device) + raise TypeError( + f"env_ids must be None, slice(None), list, torch.Tensor, or wp.array, got {type(env_ids).__name__}" + ) + + def _resolve_body_ids(self, body_ids: wp.array | torch.Tensor | list | slice | None) -> wp.array: + """Resolve body IDs to a warp int32 array. + + Args: + body_ids: Body indices as any supported type, or None for all bodies. + + Returns: + Warp array of int32 body indices. + + Raises: + TypeError: If ``body_ids`` is an unsupported type. + """ + if body_ids is None: + return self._ALL_BODY_INDICES + if isinstance(body_ids, torch.Tensor): + if body_ids.dtype == torch.int64: + body_ids = body_ids.to(torch.int32) + return wp.from_torch(body_ids.contiguous(), dtype=wp.int32) + if isinstance(body_ids, wp.array): + return body_ids + if body_ids == slice(None): + return self._ALL_BODY_INDICES + if isinstance(body_ids, list): + return wp.array(body_ids, dtype=wp.int32, device=self.device) + raise TypeError( + f"body_ids must be None, slice(None), list, torch.Tensor, or wp.array, got {type(body_ids).__name__}" + ) + + def _ensure_composed(self): + """Compose input buffers into output buffers if dirty.""" + if self._dirty: + self.compose_to_body_frame() diff --git a/source/isaaclab/test/utils/test_wrench_composer.py b/source/isaaclab/test/utils/test_wrench_composer.py index 9b7c4aecaf15..37f6d5959aac 100644 --- a/source/isaaclab/test/utils/test_wrench_composer.py +++ b/source/isaaclab/test/utils/test_wrench_composer.py @@ -131,11 +131,13 @@ def test_wrench_composer_add_force(device: str, num_envs: int, num_bodies: int): ) forces = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) # Add forces to wrench composer - wrench_composer.add_forces_and_torques(forces=forces, body_ids=body_ids, env_ids=env_ids) + wrench_composer.add_forces_and_torques_index(forces=forces, body_ids=body_ids, env_ids=env_ids) # Add forces to hand-calculated composed force hand_calculated_composed_force_np[env_ids_np[:, None], body_ids_np[None, :], :] += forces_np + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() # Get composed force from wrench composer - composed_force_np = wrench_composer.composed_force.numpy() + composed_force_np = wrench_composer.out_force_b.numpy() assert np.allclose(composed_force_np, hand_calculated_composed_force_np, atol=1, rtol=1e-7) @@ -168,18 +170,20 @@ def test_wrench_composer_add_torque(device: str, num_envs: int, num_bodies: int) ) torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) # Add torques to wrench composer - wrench_composer.add_forces_and_torques(torques=torques, body_ids=body_ids, env_ids=env_ids) + wrench_composer.add_forces_and_torques_index(torques=torques, body_ids=body_ids, env_ids=env_ids) # Add torques to hand-calculated composed torque hand_calculated_composed_torque_np[env_ids_np[:, None], body_ids_np[None, :], :] += torques_np + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() # Get composed torque from wrench composer - composed_torque_np = wrench_composer.composed_torque.numpy() + composed_torque_np = wrench_composer.out_torque_b.numpy() assert np.allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1, rtol=1e-7) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) @pytest.mark.parametrize("num_envs", [1, 10, 100, 1000]) @pytest.mark.parametrize("num_bodies", [1, 3, 5, 10]) -def test_add_forces_at_positons(device: str, num_envs: int, num_bodies: int): +def test_add_forces_at_positions(device: str, num_envs: int, num_bodies: int): """Test adding forces at local positions (offset from link frame).""" rng = np.random.default_rng(seed=2) @@ -214,7 +218,7 @@ def test_add_forces_at_positons(device: str, num_envs: int, num_bodies: int): forces = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) positions = wp.from_numpy(positions_np, dtype=wp.vec3f, device=device) # Add forces at positions to wrench composer - wrench_composer.add_forces_and_torques( + wrench_composer.add_forces_and_torques_index( forces=forces, positions=positions, body_ids=body_ids, env_ids=env_ids ) # Add forces to hand-calculated composed force @@ -225,11 +229,13 @@ def test_add_forces_at_positons(device: str, num_envs: int, num_bodies: int): for j in range(num_bodies_np): hand_calculated_composed_torque_np[env_ids_np[i], body_ids_np[j], :] += torques_from_forces[i, j, :] + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() # Get composed force from wrench composer - composed_force_np = wrench_composer.composed_force.numpy() + composed_force_np = wrench_composer.out_force_b.numpy() assert np.allclose(composed_force_np, hand_calculated_composed_force_np, atol=1, rtol=1e-7) # Get composed torque from wrench composer - composed_torque_np = wrench_composer.composed_torque.numpy() + composed_torque_np = wrench_composer.out_torque_b.numpy() assert np.allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1, rtol=1e-7) @@ -267,13 +273,15 @@ def test_add_torques_at_position(device: str, num_envs: int, num_bodies: int): torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) positions = wp.from_numpy(positions_np, dtype=wp.vec3f, device=device) # Add torques at positions to wrench composer - wrench_composer.add_forces_and_torques( + wrench_composer.add_forces_and_torques_index( torques=torques, positions=positions, body_ids=body_ids, env_ids=env_ids ) # Add torques to hand-calculated composed torque hand_calculated_composed_torque_np[env_ids_np[:, None], body_ids_np[None, :], :] += torques_np + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() # Get composed torque from wrench composer - composed_torque_np = wrench_composer.composed_torque.numpy() + composed_torque_np = wrench_composer.out_torque_b.numpy() assert np.allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1, rtol=1e-7) @@ -319,7 +327,7 @@ def test_add_forces_and_torques_at_position(device: str, num_envs: int, num_bodi torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) positions = wp.from_numpy(positions_np, dtype=wp.vec3f, device=device) # Add forces and torques at positions to wrench composer - wrench_composer.add_forces_and_torques( + wrench_composer.add_forces_and_torques_index( forces=forces, torques=torques, positions=positions, body_ids=body_ids, env_ids=env_ids ) # Add forces to hand-calculated composed force @@ -330,11 +338,13 @@ def test_add_forces_and_torques_at_position(device: str, num_envs: int, num_bodi for j in range(num_bodies_np): hand_calculated_composed_torque_np[env_ids_np[i], body_ids_np[j], :] += torques_from_forces[i, j, :] hand_calculated_composed_torque_np[env_ids_np[:, None], body_ids_np[None, :], :] += torques_np + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() # Get composed force from wrench composer - composed_force_np = wrench_composer.composed_force.numpy() + composed_force_np = wrench_composer.out_force_b.numpy() assert np.allclose(composed_force_np, hand_calculated_composed_force_np, atol=1, rtol=1e-7) # Get composed torque from wrench composer - composed_torque_np = wrench_composer.composed_torque.numpy() + composed_torque_np = wrench_composer.out_torque_b.numpy() assert np.allclose(composed_torque_np, hand_calculated_composed_torque_np, atol=1, rtol=1e-7) @@ -368,14 +378,18 @@ def test_wrench_composer_reset(device: str, num_envs: int, num_bodies: int): forces = wp.from_numpy(forces_np, dtype=wp.vec3f, device=device) torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) # Add forces and torques to wrench composer - wrench_composer.add_forces_and_torques(forces=forces, torques=torques, body_ids=body_ids, env_ids=env_ids) + wrench_composer.add_forces_and_torques_index(forces=forces, torques=torques, body_ids=body_ids, env_ids=env_ids) # Reset wrench composer wrench_composer.reset() - # Get composed force and torque from wrench composer - composed_force_np = wrench_composer.composed_force.numpy() - composed_torque_np = wrench_composer.composed_torque.numpy() - assert np.allclose(composed_force_np, np.zeros((num_envs, num_bodies, 3)), atol=1, rtol=1e-7) - assert np.allclose(composed_torque_np, np.zeros((num_envs, num_bodies, 3)), atol=1, rtol=1e-7) + # Check all 7 buffers are zero (5 input + 2 output) + zeros = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + assert np.allclose(wrench_composer.global_force_w.numpy(), zeros, atol=1, rtol=1e-7) + assert np.allclose(wrench_composer.global_torque_w.numpy(), zeros, atol=1, rtol=1e-7) + assert np.allclose(wrench_composer.global_force_at_com_w.numpy(), zeros, atol=1, rtol=1e-7) + assert np.allclose(wrench_composer.local_force_b.numpy(), zeros, atol=1, rtol=1e-7) + assert np.allclose(wrench_composer.local_torque_b.numpy(), zeros, atol=1, rtol=1e-7) + assert np.allclose(wrench_composer.out_force_b.numpy(), zeros, atol=1, rtol=1e-7) + assert np.allclose(wrench_composer.out_torque_b.numpy(), zeros, atol=1, rtol=1e-7) # ============================================================================ @@ -404,13 +418,22 @@ def test_global_forces_with_rotation(device: str, num_envs: int, num_bodies: int forces_global = wp.from_numpy(forces_global_np, dtype=wp.vec3f, device=device) # Apply global forces - wrench_composer.add_forces_and_torques(forces=forces_global, is_global=True) + wrench_composer.add_forces_and_torques_index(forces=forces_global, is_global=True) # Compute expected local forces by rotating global forces by inverse quaternion expected_forces_local = quat_rotate_inv_np(link_quat_np, forces_global_np) + # Check raw global buffer has the global forces + global_force_np = wrench_composer.global_force_at_com_w.numpy() + assert np.allclose(global_force_np, forces_global_np, atol=1e-4, rtol=1e-5), ( + f"Global force buffer mismatch.\nExpected:\n{forces_global_np}\nGot:\n{global_force_np}" + ) + + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() + # Verify - composed_force_np = wrench_composer.composed_force.numpy() + composed_force_np = wrench_composer.out_force_b.numpy() assert np.allclose(composed_force_np, expected_forces_local, atol=1e-4, rtol=1e-5), ( f"Global force rotation failed.\nExpected:\n{expected_forces_local}\nGot:\n{composed_force_np}" ) @@ -437,13 +460,22 @@ def test_global_torques_with_rotation(device: str, num_envs: int, num_bodies: in torques_global = wp.from_numpy(torques_global_np, dtype=wp.vec3f, device=device) # Apply global torques - wrench_composer.add_forces_and_torques(torques=torques_global, is_global=True) + wrench_composer.add_forces_and_torques_index(torques=torques_global, is_global=True) # Compute expected local torques expected_torques_local = quat_rotate_inv_np(link_quat_np, torques_global_np) + # Check raw global buffer has the global torques + global_torque_np = wrench_composer.global_torque_w.numpy() + assert np.allclose(global_torque_np, torques_global_np, atol=1e-4, rtol=1e-5), ( + f"Global torque buffer mismatch.\nExpected:\n{torques_global_np}\nGot:\n{global_torque_np}" + ) + + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() + # Verify - composed_torque_np = wrench_composer.composed_torque.numpy() + composed_torque_np = wrench_composer.out_torque_b.numpy() assert np.allclose(composed_torque_np, expected_torques_local, atol=1e-4, rtol=1e-5), ( f"Global torque rotation failed.\nExpected:\n{expected_torques_local}\nGot:\n{composed_torque_np}" ) @@ -474,32 +506,40 @@ def test_global_forces_at_global_position(device: str, num_envs: int, num_bodies positions_global = wp.from_numpy(positions_global_np, dtype=wp.vec3f, device=device) # Apply global forces at global positions - wrench_composer.add_forces_and_torques(forces=forces_global, positions=positions_global, is_global=True) + wrench_composer.add_forces_and_torques_index(forces=forces_global, positions=positions_global, is_global=True) # Compute expected results: # 1. Force in local frame = quat_rotate_inv(link_quat, global_force) expected_forces_local = quat_rotate_inv_np(link_quat_np, forces_global_np) - # 2. Position offset in local frame = global_position - link_position (then used for torque) + # 2. Torque about CoM in world frame = cross(P_global - link_pos, F_global) + # Then rotate to body frame position_offset_global = positions_global_np - link_pos_np - - # 3. Torque = skew(position_offset_global) @ force_global, then rotate to local expected_torques_local = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) for i in range(num_envs): for j in range(num_bodies): - pos_offset = position_offset_global[i, j] # global frame offset - force_local = expected_forces_local[i, j] # local frame force - # skew(pos_offset) @ force_local - expected_torques_local[i, j] = np.cross(pos_offset, force_local) + torque_w = np.cross(position_offset_global[i, j], forces_global_np[i, j]) + expected_torques_local[i, j] = quat_rotate_inv_np( + link_quat_np[i : i + 1, j : j + 1], torque_w.reshape(1, 1, 3) + )[0, 0] + + # Check raw global force buffer has the global forces + global_force_np = wrench_composer.global_force_w.numpy() + assert np.allclose(global_force_np, forces_global_np, atol=1e-4, rtol=1e-5), ( + f"Global force buffer mismatch.\nExpected:\n{forces_global_np}\nGot:\n{global_force_np}" + ) + + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() # Verify forces - composed_force_np = wrench_composer.composed_force.numpy() + composed_force_np = wrench_composer.out_force_b.numpy() assert np.allclose(composed_force_np, expected_forces_local, atol=1e-3, rtol=1e-4), ( f"Global force at position failed.\nExpected forces:\n{expected_forces_local}\nGot:\n{composed_force_np}" ) # Verify torques - composed_torque_np = wrench_composer.composed_torque.numpy() + composed_torque_np = wrench_composer.out_torque_b.numpy() assert np.allclose(composed_torque_np, expected_torques_local, atol=1e-3, rtol=1e-4), ( f"Global force at position failed.\nExpected torques:\n{expected_torques_local}\nGot:\n{composed_torque_np}" ) @@ -525,20 +565,24 @@ def test_local_vs_global_identity_quaternion(device: str): torques = wp.from_numpy(torques_np, dtype=wp.vec3f, device=device) # Apply as local - wrench_composer_local.add_forces_and_torques(forces=forces, torques=torques, is_global=False) + wrench_composer_local.add_forces_and_torques_index(forces=forces, torques=torques, is_global=False) # Apply as global (should be same with identity quaternion) - wrench_composer_global.add_forces_and_torques(forces=forces, torques=torques, is_global=True) + wrench_composer_global.add_forces_and_torques_index(forces=forces, torques=torques, is_global=True) + + # Compose to body frame before checking output + wrench_composer_local.compose_to_body_frame() + wrench_composer_global.compose_to_body_frame() # Results should be identical assert np.allclose( - wrench_composer_local.composed_force.numpy(), - wrench_composer_global.composed_force.numpy(), + wrench_composer_local.out_force_b.numpy(), + wrench_composer_global.out_force_b.numpy(), atol=1e-6, ) assert np.allclose( - wrench_composer_local.composed_torque.numpy(), - wrench_composer_global.composed_torque.numpy(), + wrench_composer_local.out_torque_b.numpy(), + wrench_composer_global.out_torque_b.numpy(), atol=1e-6, ) @@ -561,13 +605,16 @@ def test_90_degree_rotation_global_force(device: str): force_global = np.array([[[1.0, 0.0, 0.0]]], dtype=np.float32) force_wp = wp.from_numpy(force_global, dtype=wp.vec3f, device=device) - wrench_composer.add_forces_and_torques(forces=force_wp, is_global=True) + wrench_composer.add_forces_and_torques_index(forces=force_wp, is_global=True) # Expected: After inverse rotation (rotate by -90° around Z), X becomes -Y # Actually, inverse rotation of +90° around Z applied to (1,0,0) gives (0,-1,0) expected_force_local = np.array([[[0.0, -1.0, 0.0]]], dtype=np.float32) - composed_force_np = wrench_composer.composed_force.numpy() + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() + + composed_force_np = wrench_composer.out_force_b.numpy() assert np.allclose(composed_force_np, expected_force_local, atol=1e-5), ( f"90-degree rotation test failed.\nExpected:\n{expected_force_local}\nGot:\n{composed_force_np}" ) @@ -594,16 +641,25 @@ def test_composition_mixed_local_and_global(device: str): forces_global = wp.from_numpy(forces_global_np, dtype=wp.vec3f, device=device) # Add local forces first - wrench_composer.add_forces_and_torques(forces=forces_local, is_global=False) + wrench_composer.add_forces_and_torques_index(forces=forces_local, is_global=False) # Add global forces - wrench_composer.add_forces_and_torques(forces=forces_global, is_global=True) + wrench_composer.add_forces_and_torques_index(forces=forces_global, is_global=True) # Expected: local forces stay as-is, global forces get rotated, then sum global_forces_in_local = quat_rotate_inv_np(link_quat_np, forces_global_np) expected_total = forces_local_np + global_forces_in_local - composed_force_np = wrench_composer.composed_force.numpy() + # Check raw buffer properties + local_force_np = wrench_composer.local_force_b.numpy() + assert np.allclose(local_force_np, forces_local_np, atol=1e-4, rtol=1e-5) + global_force_at_com_np = wrench_composer.global_force_at_com_w.numpy() + assert np.allclose(global_force_at_com_np, forces_global_np, atol=1e-4, rtol=1e-5) + + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() + + composed_force_np = wrench_composer.out_force_b.numpy() assert np.allclose(composed_force_np, expected_total, atol=1e-4, rtol=1e-5), ( f"Mixed local/global composition failed.\nExpected:\n{expected_total}\nGot:\n{composed_force_np}" ) @@ -633,15 +689,22 @@ def test_local_forces_at_local_position(device: str, num_envs: int, num_bodies: positions_local = wp.from_numpy(positions_local_np, dtype=wp.vec3f, device=device) # Apply local forces at local positions - wrench_composer.add_forces_and_torques(forces=forces_local, positions=positions_local, is_global=False) + wrench_composer.add_forces_and_torques_index(forces=forces_local, positions=positions_local, is_global=False) # Expected: forces stay as-is, torque = cross(position, force) expected_forces = forces_local_np expected_torques = np.cross(positions_local_np, forces_local_np) + # Check raw local buffer + local_force_np = wrench_composer.local_force_b.numpy() + assert np.allclose(local_force_np, expected_forces, atol=1e-4, rtol=1e-5) + + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() + # Verify - composed_force_np = wrench_composer.composed_force.numpy() - composed_torque_np = wrench_composer.composed_torque.numpy() + composed_force_np = wrench_composer.out_force_b.numpy() + composed_torque_np = wrench_composer.out_torque_b.numpy() assert np.allclose(composed_force_np, expected_forces, atol=1e-4, rtol=1e-5) assert np.allclose(composed_torque_np, expected_torques, atol=1e-4, rtol=1e-5) @@ -670,14 +733,972 @@ def test_global_force_at_link_origin_no_torque(device: str): positions_at_link = wp.from_numpy(link_pos_np, dtype=wp.vec3f, device=device) # Apply global forces at link origin - wrench_composer.add_forces_and_torques(forces=forces_global, positions=positions_at_link, is_global=True) + wrench_composer.add_forces_and_torques_index(forces=forces_global, positions=positions_at_link, is_global=True) # Expected: force rotated to local, torque = 0 (since position offset is zero) expected_forces = quat_rotate_inv_np(link_quat_np, forces_global_np) expected_torques = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) - composed_force_np = wrench_composer.composed_force.numpy() - composed_torque_np = wrench_composer.composed_torque.numpy() + # Check raw global force buffer + global_force_np = wrench_composer.global_force_w.numpy() + assert np.allclose(global_force_np, forces_global_np, atol=1e-4, rtol=1e-5) + + # Compose to body frame before checking output + wrench_composer.compose_to_body_frame() + + composed_force_np = wrench_composer.out_force_b.numpy() + composed_torque_np = wrench_composer.out_torque_b.numpy() assert np.allclose(composed_force_np, expected_forces, atol=1e-4, rtol=1e-5) assert np.allclose(composed_torque_np, expected_torques, atol=1e-4, rtol=1e-5) + + +# ============================================================================ +# add_raw_buffers_from Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("num_envs", [1, 10, 100]) +@pytest.mark.parametrize("num_bodies", [1, 3, 5]) +def test_add_raw_buffers_from(device: str, num_envs: int, num_bodies: int): + """Test that add_raw_buffers_from merges all five input buffers correctly.""" + rng = np.random.default_rng(seed=20) + + # Create two composers with random link poses + link_pos_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) + link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) + link_pos_torch = torch.from_numpy(link_pos_np) + link_quat_torch = torch.from_numpy(link_quat_np) + + mock_a = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) + mock_b = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) + + composer_a = WrenchComposer(mock_a) + composer_b = WrenchComposer(mock_b) + + # Populate composer_a with local forces at positions + forces_local_a_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + positions_local_a_np = rng.uniform(-5.0, 5.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer_a.add_forces_and_torques_index( + forces=wp.from_numpy(forces_local_a_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_local_a_np, dtype=wp.vec3f, device=device), + is_global=False, + ) + + # Populate composer_b with global forces at global positions + forces_global_b_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + positions_global_b_np = rng.uniform(-5.0, 5.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer_b.add_forces_and_torques_index( + forces=wp.from_numpy(forces_global_b_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_global_b_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Merge b into a + composer_a.add_raw_buffers_from(composer_b) + + # Build a reference composer that receives both calls directly + mock_ref = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) + composer_ref = WrenchComposer(mock_ref) + composer_ref.add_forces_and_torques_index( + forces=wp.from_numpy(forces_local_a_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_local_a_np, dtype=wp.vec3f, device=device), + is_global=False, + ) + composer_ref.add_forces_and_torques_index( + forces=wp.from_numpy(forces_global_b_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_global_b_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Compose both and compare + composer_a.compose_to_body_frame() + composer_ref.compose_to_body_frame() + + assert np.allclose(composer_a.out_force_b.numpy(), composer_ref.out_force_b.numpy(), atol=1e-4, rtol=1e-5), ( + "add_raw_buffers_from force mismatch vs direct accumulation" + ) + assert np.allclose(composer_a.out_torque_b.numpy(), composer_ref.out_torque_b.numpy(), atol=1e-4, rtol=1e-5), ( + "add_raw_buffers_from torque mismatch vs direct accumulation" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_add_raw_buffers_from_inactive_is_noop(device: str): + """Test that add_raw_buffers_from is a no-op when the source composer is inactive.""" + num_envs, num_bodies = 4, 2 + rng = np.random.default_rng(seed=21) + + mock_a = create_mock_asset(num_envs, num_bodies, device) + mock_b = create_mock_asset(num_envs, num_bodies, device) + composer_a = WrenchComposer(mock_a) + composer_b = WrenchComposer(mock_b) + + # Populate composer_a with some forces + forces_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer_a.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + ) + + # composer_b is inactive (never written to) + assert not composer_b.active + + # Snapshot composer_a's local buffer before merge + local_force_before = composer_a.local_force_b.numpy().copy() + + # Merge inactive composer_b into composer_a -- should be a no-op + composer_a.add_raw_buffers_from(composer_b) + + assert np.allclose(composer_a.local_force_b.numpy(), local_force_before, atol=1e-7) + + +# ============================================================================ +# Mask-based API Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("num_envs", [1, 10, 100]) +@pytest.mark.parametrize("num_bodies", [1, 3, 5]) +def test_add_forces_mask(device: str, num_envs: int, num_bodies: int): + """Test that add_forces_and_torques_mask produces the same result as the index variant.""" + rng = np.random.default_rng(seed=30) + + for _ in range(5): + # Random subset selection + env_select = rng.choice([True, False], size=num_envs, replace=True) + body_select = rng.choice([True, False], size=num_bodies, replace=True) + # Ensure at least one env and body are selected + env_select[0] = True + body_select[0] = True + + env_ids_np = np.where(env_select)[0].astype(np.int32) + body_ids_np = np.where(body_select)[0].astype(np.int32) + env_mask_np = env_select.astype(np.bool_) + body_mask_np = body_select.astype(np.bool_) + + # Random forces for the full grid (mask variant takes full-sized arrays) + forces_full_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + + # Index-based composer + mock_idx = create_mock_asset(num_envs, num_bodies, device) + composer_idx = WrenchComposer(mock_idx) + # Extract the subset for index API + forces_subset_np = forces_full_np[env_ids_np[:, None], body_ids_np[None, :], :] + composer_idx.add_forces_and_torques_index( + forces=wp.from_numpy(forces_subset_np, dtype=wp.vec3f, device=device), + env_ids=wp.from_numpy(env_ids_np, dtype=wp.int32, device=device), + body_ids=wp.from_numpy(body_ids_np, dtype=wp.int32, device=device), + ) + + # Mask-based composer + mock_mask = create_mock_asset(num_envs, num_bodies, device) + composer_mask = WrenchComposer(mock_mask) + composer_mask.add_forces_and_torques_mask( + forces=wp.from_numpy(forces_full_np, dtype=wp.vec3f, device=device), + env_mask=wp.from_numpy(env_mask_np, dtype=wp.bool, device=device), + body_mask=wp.from_numpy(body_mask_np, dtype=wp.bool, device=device), + ) + + # Compose both + composer_idx.compose_to_body_frame() + composer_mask.compose_to_body_frame() + + assert np.allclose(composer_idx.out_force_b.numpy(), composer_mask.out_force_b.numpy(), atol=1e-4, rtol=1e-5), ( + f"Mask vs index force mismatch (envs={num_envs}, bodies={num_bodies})" + ) + assert np.allclose( + composer_idx.out_torque_b.numpy(), composer_mask.out_torque_b.numpy(), atol=1e-4, rtol=1e-5 + ), f"Mask vs index torque mismatch (envs={num_envs}, bodies={num_bodies})" + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +@pytest.mark.parametrize("num_envs", [1, 10, 100]) +@pytest.mark.parametrize("num_bodies", [1, 3, 5]) +def test_add_forces_mask_global(device: str, num_envs: int, num_bodies: int): + """Test mask-based API with global forces and positions.""" + rng = np.random.default_rng(seed=31) + + # Random link poses + link_pos_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) + link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) + link_pos_torch = torch.from_numpy(link_pos_np) + link_quat_torch = torch.from_numpy(link_quat_np) + + # Select all envs and bodies to keep comparison simple + forces_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + positions_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) + + # Index-based + mock_idx = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) + composer_idx = WrenchComposer(mock_idx) + composer_idx.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Mask-based (all-True masks) + mock_mask = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) + composer_mask = WrenchComposer(mock_mask) + env_mask = wp.from_numpy(np.ones(num_envs, dtype=np.bool_), dtype=wp.bool, device=device) + body_mask = wp.from_numpy(np.ones(num_bodies, dtype=np.bool_), dtype=wp.bool, device=device) + composer_mask.add_forces_and_torques_mask( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + env_mask=env_mask, + body_mask=body_mask, + is_global=True, + ) + + composer_idx.compose_to_body_frame() + composer_mask.compose_to_body_frame() + + assert np.allclose(composer_idx.out_force_b.numpy(), composer_mask.out_force_b.numpy(), atol=1e-4, rtol=1e-5), ( + "Mask vs index global force mismatch" + ) + assert np.allclose(composer_idx.out_torque_b.numpy(), composer_mask.out_torque_b.numpy(), atol=1e-4, rtol=1e-5), ( + "Mask vs index global torque mismatch" + ) + + +# ============================================================================ +# set_forces_and_torques_index Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_set_forces_overwrites_previous_add(device: str): + """Test that set_forces_and_torques_index clears previously accumulated values.""" + num_envs, num_bodies = 4, 2 + rng = np.random.default_rng(seed=40) + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + # First accumulate some forces via add + forces_a_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_a_np, dtype=wp.vec3f, device=device), + ) + + # Now set new forces -- should replace, not accumulate + forces_b_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.set_forces_and_torques_index( + forces=wp.from_numpy(forces_b_np, dtype=wp.vec3f, device=device), + ) + + composer.compose_to_body_frame() + + # Output should match forces_b only (forces_a should be gone) + assert np.allclose(composer.out_force_b.numpy(), forces_b_np, atol=1e-4, rtol=1e-5), ( + "set_forces did not clear previous add" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_set_forces_clears_targeted_envs_only(device: str): + """Test that set_forces_and_torques_index clears only the targeted environments.""" + num_envs, num_bodies = 4, 3 + rng = np.random.default_rng(seed=41) + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + # Add global forces at positions (populates global_force_w and global_torque_w) + forces_global_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + positions_np = rng.uniform(-5.0, 5.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_global_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Also add local torques (populates local_torque_b) + torques_local_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + torques=wp.from_numpy(torques_local_np, dtype=wp.vec3f, device=device), + is_global=False, + ) + + # Now set local forces for envs [0, 2] -- should clear only envs 0, 2 + env_ids_np = np.array([0, 2], dtype=np.int32) + kept_env_ids = np.array([1, 3], dtype=np.int32) + forces_new_np = rng.uniform(-50.0, 50.0, (2, num_bodies, 3)).astype(np.float32) + composer.set_forces_and_torques_index( + forces=wp.from_numpy(forces_new_np, dtype=wp.vec3f, device=device), + env_ids=wp.from_numpy(env_ids_np, dtype=wp.int32, device=device), + is_global=False, + ) + + zeros = np.zeros((num_bodies, 3), dtype=np.float32) + + # Targeted envs [0, 2]: all buffers cleared, then local_force_b written + for eid in env_ids_np: + assert np.allclose(composer.global_force_w.numpy()[eid], zeros, atol=1e-7), ( + f"global_force_w not cleared for targeted env {eid}" + ) + assert np.allclose(composer.global_torque_w.numpy()[eid], zeros, atol=1e-7), ( + f"global_torque_w not cleared for targeted env {eid}" + ) + assert np.allclose(composer.local_torque_b.numpy()[eid], zeros, atol=1e-7), ( + f"local_torque_b not cleared for targeted env {eid}" + ) + + # Non-targeted envs [1, 3]: should retain original values + for eid in kept_env_ids: + assert np.allclose(composer.global_force_w.numpy()[eid], forces_global_np[eid], atol=1e-4, rtol=1e-5), ( + f"global_force_w changed for non-targeted env {eid}" + ) + assert np.allclose(composer.local_torque_b.numpy()[eid], torques_local_np[eid], atol=1e-4, rtol=1e-5), ( + f"local_torque_b changed for non-targeted env {eid}" + ) + + # local_force_b should have new values at env_ids [0, 2], zeros at [1, 3] + expected_local_force = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + expected_local_force[env_ids_np] = forces_new_np + assert np.allclose(composer.local_force_b.numpy(), expected_local_force, atol=1e-4, rtol=1e-5), ( + "local_force_b has wrong values after set" + ) + + +# ============================================================================ +# Partial Reset Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_partial_reset_zeros_only_specified_envs(device: str): + """Test that partial reset zeros only the specified environments and leaves others intact.""" + num_envs, num_bodies = 8, 3 + rng = np.random.default_rng(seed=50) + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + # Populate all envs with local forces + forces_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + ) + + # Also add global forces to populate more buffers + forces_global_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_global_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Partial reset: only envs [1, 3, 5] + reset_env_ids = np.array([1, 3, 5], dtype=np.int32) + kept_env_ids = np.array([0, 2, 4, 6, 7], dtype=np.int32) + composer.reset(env_ids=wp.from_numpy(reset_env_ids, dtype=wp.int32, device=device)) + + # Reset envs should be zeroed across all input buffers + zeros = np.zeros((num_bodies, 3), dtype=np.float32) + local_force = composer.local_force_b.numpy() + global_force_at_com = composer.global_force_at_com_w.numpy() + for eid in reset_env_ids: + assert np.allclose(local_force[eid], zeros, atol=1e-7), f"local_force_b not zeroed for env {eid}" + assert np.allclose(global_force_at_com[eid], zeros, atol=1e-7), ( + f"global_force_at_com_w not zeroed for env {eid}" + ) + + # Kept envs should retain their values + for eid in kept_env_ids: + assert np.allclose(local_force[eid], forces_np[eid], atol=1e-4, rtol=1e-5), ( + f"local_force_b changed for non-reset env {eid}" + ) + assert np.allclose(global_force_at_com[eid], forces_global_np[eid], atol=1e-4, rtol=1e-5), ( + f"global_force_at_com_w changed for non-reset env {eid}" + ) + + # Flags: _active should still be True, _dirty should be True + assert composer.active + assert composer._dirty + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_full_reset_clears_active_flag(device: str): + """Test that full reset (no args) clears the _active flag.""" + num_envs, num_bodies = 4, 2 + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + forces_np = np.ones((num_envs, num_bodies, 3), dtype=np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + ) + assert composer.active + + composer.reset() + assert not composer.active + assert not composer._dirty + + +# ============================================================================ +# Deprecated API Backward-Compatibility Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composed_force_emits_deprecation_warning(device: str): + """Test that accessing composed_force emits a DeprecationWarning.""" + num_envs, num_bodies = 2, 1 + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + forces_np = np.array([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]], dtype=np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + ) + + with pytest.warns(DeprecationWarning, match="composed_force.*is deprecated"): + result = composer.composed_force + + # Should return the same data as out_force_b + assert np.allclose(result.numpy(), composer.out_force_b.numpy(), atol=1e-7) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composed_torque_emits_deprecation_warning(device: str): + """Test that accessing composed_torque emits a DeprecationWarning.""" + num_envs, num_bodies = 2, 1 + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + torques_np = np.array([[[1.0, 2.0, 3.0]], [[4.0, 5.0, 6.0]]], dtype=np.float32) + composer.add_forces_and_torques_index( + torques=wp.from_numpy(torques_np, dtype=wp.vec3f, device=device), + ) + + with pytest.warns(DeprecationWarning, match="composed_torque.*is deprecated"): + result = composer.composed_torque + + assert np.allclose(result.numpy(), composer.out_torque_b.numpy(), atol=1e-7) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_deprecated_add_forces_and_torques_emits_warning(device: str): + """Test that the deprecated add_forces_and_torques wrapper emits a warning and works.""" + num_envs, num_bodies = 4, 2 + rng = np.random.default_rng(seed=52) + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + forces_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + + with pytest.warns(DeprecationWarning, match="add_forces_and_torques.*is deprecated"): + composer.add_forces_and_torques( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + ) + + composer.compose_to_body_frame() + assert np.allclose(composer.out_force_b.numpy(), forces_np, atol=1e-4, rtol=1e-5) + + +# ============================================================================ +# set_forces_and_torques_mask Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_set_forces_mask_overwrites_previous_add(device: str): + """Test that set_forces_and_torques_mask clears previously accumulated values.""" + num_envs, num_bodies = 4, 2 + rng = np.random.default_rng(seed=60) + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + # Accumulate some forces via add + forces_a_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_a_np, dtype=wp.vec3f, device=device), + ) + + # Now set new forces via mask -- should replace, not accumulate + forces_b_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.set_forces_and_torques_mask( + forces=wp.from_numpy(forces_b_np, dtype=wp.vec3f, device=device), + ) + + composer.compose_to_body_frame() + + # Output should match forces_b only (forces_a should be gone) + assert np.allclose(composer.out_force_b.numpy(), forces_b_np, atol=1e-4, rtol=1e-5), ( + "set_forces_and_torques_mask did not clear previous add" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_set_forces_mask_clears_targeted_envs_only(device: str): + """Test that set_forces_and_torques_mask clears only the masked environments.""" + num_envs, num_bodies = 4, 3 + rng = np.random.default_rng(seed=61) + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + # Populate global buffers for all envs + forces_global_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + positions_np = rng.uniform(-5.0, 5.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_global_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Also add local torques for all envs + torques_local_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + torques=wp.from_numpy(torques_local_np, dtype=wp.vec3f, device=device), + is_global=False, + ) + + # Set local forces via mask for envs [0, 2] -- should clear only masked envs + env_mask_np = np.array([True, False, True, False], dtype=np.bool_) + body_mask_np = np.array([True, True, False], dtype=np.bool_) + forces_new_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.set_forces_and_torques_mask( + forces=wp.from_numpy(forces_new_np, dtype=wp.vec3f, device=device), + env_mask=wp.from_numpy(env_mask_np, dtype=wp.bool, device=device), + body_mask=wp.from_numpy(body_mask_np, dtype=wp.bool, device=device), + is_global=False, + ) + + zeros = np.zeros((num_bodies, 3), dtype=np.float32) + + # Masked envs [0, 2]: all buffers cleared by reset, then local_force_b written where body_mask is True + for eid in [0, 2]: + assert np.allclose(composer.global_force_w.numpy()[eid], zeros, atol=1e-7), ( + f"global_force_w not cleared for masked env {eid}" + ) + assert np.allclose(composer.global_torque_w.numpy()[eid], zeros, atol=1e-7), ( + f"global_torque_w not cleared for masked env {eid}" + ) + assert np.allclose(composer.local_torque_b.numpy()[eid], zeros, atol=1e-7), ( + f"local_torque_b not cleared for masked env {eid}" + ) + + # Non-masked envs [1, 3]: should retain original values + for eid in [1, 3]: + assert np.allclose(composer.global_force_w.numpy()[eid], forces_global_np[eid], atol=1e-4, rtol=1e-5), ( + f"global_force_w changed for non-masked env {eid}" + ) + assert np.allclose(composer.local_torque_b.numpy()[eid], torques_local_np[eid], atol=1e-4, rtol=1e-5), ( + f"local_torque_b changed for non-masked env {eid}" + ) + + # local_force_b should have new values where both masks are True, zeros for masked envs otherwise + expected_local_force = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + for e in range(num_envs): + for b in range(num_bodies): + if env_mask_np[e] and body_mask_np[b]: + expected_local_force[e, b] = forces_new_np[e, b] + assert np.allclose(composer.local_force_b.numpy(), expected_local_force, atol=1e-4, rtol=1e-5), ( + "local_force_b has wrong values after mask set" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_set_forces_mask_matches_set_forces_index(device: str): + """Test that set_forces_and_torques_mask produces the same result as the index variant.""" + num_envs, num_bodies = 6, 3 + rng = np.random.default_rng(seed=62) + + # Random link poses + link_pos_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) + link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) + link_pos_torch = torch.from_numpy(link_pos_np) + link_quat_torch = torch.from_numpy(link_quat_np) + + # Use all envs/bodies to compare + forces_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + positions_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) + + # Index-based + mock_idx = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) + composer_idx = WrenchComposer(mock_idx) + composer_idx.set_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Mask-based (all-True) + mock_mask = create_mock_asset(num_envs, num_bodies, device, link_pos=link_pos_torch, link_quat=link_quat_torch) + composer_mask = WrenchComposer(mock_mask) + composer_mask.set_forces_and_torques_mask( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + composer_idx.compose_to_body_frame() + composer_mask.compose_to_body_frame() + + assert np.allclose(composer_idx.out_force_b.numpy(), composer_mask.out_force_b.numpy(), atol=1e-4, rtol=1e-5), ( + "set mask vs index force mismatch" + ) + assert np.allclose(composer_idx.out_torque_b.numpy(), composer_mask.out_torque_b.numpy(), atol=1e-4, rtol=1e-5), ( + "set mask vs index torque mismatch" + ) + + +# ============================================================================ +# Lazy Composition (_ensure_composed) Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_out_force_b_triggers_lazy_composition(device: str): + """Test that accessing out_force_b without explicit compose_to_body_frame still returns correct results.""" + num_envs, num_bodies = 4, 2 + rng = np.random.default_rng(seed=70) + + link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) + link_quat_torch = torch.from_numpy(link_quat_np) + + mock_asset = create_mock_asset(num_envs, num_bodies, device, link_quat=link_quat_torch) + composer = WrenchComposer(mock_asset) + + forces_global_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_global_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Do NOT call compose_to_body_frame -- rely on lazy composition + expected_forces_local = quat_rotate_inv_np(link_quat_np, forces_global_np) + composed_force_np = composer.out_force_b.numpy() + + assert np.allclose(composed_force_np, expected_forces_local, atol=1e-4, rtol=1e-5), ( + "Lazy composition via out_force_b failed" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_out_torque_b_triggers_lazy_composition(device: str): + """Test that accessing out_torque_b without explicit compose_to_body_frame still returns correct results.""" + num_envs, num_bodies = 4, 2 + rng = np.random.default_rng(seed=71) + + link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) + link_quat_torch = torch.from_numpy(link_quat_np) + + mock_asset = create_mock_asset(num_envs, num_bodies, device, link_quat=link_quat_torch) + composer = WrenchComposer(mock_asset) + + torques_global_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + torques=wp.from_numpy(torques_global_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # Do NOT call compose_to_body_frame -- rely on lazy composition + expected_torques_local = quat_rotate_inv_np(link_quat_np, torques_global_np) + composed_torque_np = composer.out_torque_b.numpy() + + assert np.allclose(composed_torque_np, expected_torques_local, atol=1e-4, rtol=1e-5), ( + "Lazy composition via out_torque_b failed" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_lazy_composition_tracks_dirty_flag(device: str): + """Test that the dirty flag is correctly managed through add/compose/add cycles.""" + num_envs, num_bodies = 2, 1 + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + # Initially clean + assert not composer._dirty + + # After add, dirty + forces_np = np.ones((num_envs, num_bodies, 3), dtype=np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + ) + assert composer._dirty + + # After accessing out_force_b, clean (lazy compose happened) + _ = composer.out_force_b + assert not composer._dirty + + # After another add, dirty again + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + ) + assert composer._dirty + + # Accessing out_torque_b also triggers composition + _ = composer.out_torque_b + assert not composer._dirty + + # Verify accumulated result (2x forces) + expected = 2.0 * forces_np + assert np.allclose(composer.out_force_b.numpy(), expected, atol=1e-4, rtol=1e-5) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_compose_is_idempotent(device: str): + """Calling compose_to_body_frame twice without intervening writes produces the same result.""" + rng = np.random.default_rng(seed=456) + num_envs, num_bodies = 4, 3 + + # Non-trivial link pose so the rotation path is exercised + link_pos_np = rng.uniform(-2, 2, (num_envs, num_bodies, 3)).astype(np.float32) + link_quat_np = rng.standard_normal((num_envs, num_bodies, 4)).astype(np.float32) + link_quat_np /= np.linalg.norm(link_quat_np, axis=-1, keepdims=True) + + mock_asset = create_mock_asset( + num_envs, + num_bodies, + device, + link_pos=torch.from_numpy(link_pos_np), + link_quat=torch.from_numpy(link_quat_np), + ) + composer = WrenchComposer(mock_asset) + + # Add global forces with positions (exercises cross-product torque path) + forces_np = rng.uniform(-5, 5, (num_envs, num_bodies, 3)).astype(np.float32) + positions_np = rng.uniform(-1, 1, (num_envs, num_bodies, 3)).astype(np.float32) + torques_np = rng.uniform(-3, 3, (num_envs, num_bodies, 3)).astype(np.float32) + + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + torques=wp.from_numpy(torques_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + # First compose + composer.compose_to_body_frame() + force_first = composer.out_force_b.numpy().copy() + torque_first = composer.out_torque_b.numpy().copy() + + # Second compose (no writes in between) + composer.compose_to_body_frame() + force_second = composer.out_force_b.numpy() + torque_second = composer.out_torque_b.numpy() + + np.testing.assert_array_equal(force_first, force_second) + np.testing.assert_array_equal(torque_first, torque_second) + + +# ============================================================================ +# CoM Offset from Link Origin Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_force_with_com_offset(device: str): + """Test that torque correction uses CoM position, not link position, when they differ.""" + num_envs, num_bodies = 2, 1 + + # Link at origin, CoM offset by [1, 0, 0] + link_pos_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + link_quat_np = np.zeros((num_envs, num_bodies, 4), dtype=np.float32) + link_quat_np[..., 3] = 1.0 # identity quaternion (xyzw) + + com_pos_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + com_pos_np[..., 0] = 1.0 # CoM at [1, 0, 0] + + mock_asset = create_mock_asset( + num_envs, + num_bodies, + device, + link_pos=torch.from_numpy(link_pos_np), + link_quat=torch.from_numpy(link_quat_np), + ) + # Set CoM pose separately (pos=[1,0,0], quat=identity) + com_pose = torch.cat([torch.from_numpy(com_pos_np), torch.from_numpy(link_quat_np)], dim=-1) + mock_asset.data.set_body_com_pose_w(com_pose) + + composer = WrenchComposer(mock_asset) + + # Apply global force [0, 0, 10] at position [0, 0, 0] (world origin) + forces_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + forces_np[..., 2] = 10.0 + positions_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + composer.compose_to_body_frame() + + # With identity quaternion: + # torque_w = cross(P, F) - cross(com, F) = cross([0,0,0], [0,0,10]) - cross([1,0,0], [0,0,10]) + # = [0,0,0] - [0*10-0*0, 0*0-1*10, 1*0-0*0] = [0,0,0] - [0, -10, 0] = [0, 10, 0] + # In body frame (identity rotation): [0, 10, 0] + expected_torque = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + expected_torque[..., 1] = 10.0 + + assert np.allclose(composer.out_torque_b.numpy(), expected_torque, atol=1e-4, rtol=1e-5), ( + f"CoM offset torque correction failed.\nExpected:\n{expected_torque}\nGot:\n{composer.out_torque_b.numpy()}" + ) + + # Force should be unchanged (identity rotation) + assert np.allclose(composer.out_force_b.numpy(), forces_np, atol=1e-4, rtol=1e-5) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_force_at_com_no_torque_with_com_offset(device: str): + """Test that a global force at CoM position produces zero torque even with CoM offset.""" + num_envs, num_bodies = 2, 1 + + # Link at origin, CoM offset by [2, 3, 0] + link_pos_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + link_quat_np = np.zeros((num_envs, num_bodies, 4), dtype=np.float32) + link_quat_np[..., 3] = 1.0 + + com_pos_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + com_pos_np[..., 0] = 2.0 + com_pos_np[..., 1] = 3.0 + + mock_asset = create_mock_asset( + num_envs, + num_bodies, + device, + link_pos=torch.from_numpy(link_pos_np), + link_quat=torch.from_numpy(link_quat_np), + ) + com_pose = torch.cat([torch.from_numpy(com_pos_np), torch.from_numpy(link_quat_np)], dim=-1) + mock_asset.data.set_body_com_pose_w(com_pose) + + composer = WrenchComposer(mock_asset) + + # Apply global force at the CoM position + forces_np = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + forces_np[..., 2] = 50.0 + positions_np = com_pos_np.copy() + + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + composer.compose_to_body_frame() + + # Torque = cross(com, F) - cross(com, F) = 0 + expected_torque = np.zeros((num_envs, num_bodies, 3), dtype=np.float32) + assert np.allclose(composer.out_torque_b.numpy(), expected_torque, atol=1e-4, rtol=1e-5), ( + "Force at CoM should produce zero torque regardless of CoM offset" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_com_offset_with_rotation(device: str): + """Test torque correction with both CoM offset and non-identity rotation.""" + num_envs, num_bodies = 1, 1 + rng = np.random.default_rng(seed=73) + + # Random rotation + link_quat_np = random_unit_quaternion_np(rng, (num_envs, num_bodies)) + link_pos_np = rng.uniform(-5.0, 5.0, (num_envs, num_bodies, 3)).astype(np.float32) + + # CoM offset from link + com_offset_np = rng.uniform(0.5, 2.0, (num_envs, num_bodies, 3)).astype(np.float32) + com_pos_np = link_pos_np + com_offset_np # simple world-frame offset for test clarity + + mock_asset = create_mock_asset( + num_envs, + num_bodies, + device, + link_pos=torch.from_numpy(link_pos_np), + link_quat=torch.from_numpy(link_quat_np), + ) + com_pose = torch.cat([torch.from_numpy(com_pos_np), torch.from_numpy(link_quat_np)], dim=-1) + mock_asset.data.set_body_com_pose_w(com_pose) + + composer = WrenchComposer(mock_asset) + + # Apply global force at a random world position + forces_np = rng.uniform(-100.0, 100.0, (num_envs, num_bodies, 3)).astype(np.float32) + positions_np = rng.uniform(-10.0, 10.0, (num_envs, num_bodies, 3)).astype(np.float32) + + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + positions=wp.from_numpy(positions_np, dtype=wp.vec3f, device=device), + is_global=True, + ) + + composer.compose_to_body_frame() + + # Expected: torque_w = cross(P, F) - cross(com, F) = cross(P - com, F) + lever_arm = positions_np - com_pos_np + torque_w = np.cross(lever_arm, forces_np) + expected_torque_b = quat_rotate_inv_np(link_quat_np, torque_w) + expected_force_b = quat_rotate_inv_np(link_quat_np, forces_np) + + assert np.allclose(composer.out_force_b.numpy(), expected_force_b, atol=1e-3, rtol=1e-4), ( + "Force mismatch with CoM offset + rotation" + ) + assert np.allclose(composer.out_torque_b.numpy(), expected_torque_b, atol=1e-3, rtol=1e-4), ( + f"Torque mismatch with CoM offset + rotation.\n" + f"Expected:\n{expected_torque_b}\nGot:\n{composer.out_torque_b.numpy()}" + ) + + +# ============================================================================ +# Deprecated set_forces_and_torques Tests +# ============================================================================ + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_deprecated_set_forces_and_torques_emits_warning(device: str): + """Test that the deprecated set_forces_and_torques wrapper emits a warning and works.""" + num_envs, num_bodies = 4, 2 + rng = np.random.default_rng(seed=80) + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + forces_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + + with pytest.warns(DeprecationWarning, match="set_forces_and_torques.*is deprecated"): + composer.set_forces_and_torques( + forces=wp.from_numpy(forces_np, dtype=wp.vec3f, device=device), + ) + + composer.compose_to_body_frame() + assert np.allclose(composer.out_force_b.numpy(), forces_np, atol=1e-4, rtol=1e-5) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_deprecated_set_forces_and_torques_clears_previous(device: str): + """Test that deprecated set_forces_and_torques actually replaces previous values.""" + num_envs, num_bodies = 4, 2 + rng = np.random.default_rng(seed=81) + + mock_asset = create_mock_asset(num_envs, num_bodies, device) + composer = WrenchComposer(mock_asset) + + # First add some forces + forces_a_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + composer.add_forces_and_torques_index( + forces=wp.from_numpy(forces_a_np, dtype=wp.vec3f, device=device), + ) + + # Then set via deprecated method -- should replace + forces_b_np = rng.uniform(-50.0, 50.0, (num_envs, num_bodies, 3)).astype(np.float32) + with pytest.warns(DeprecationWarning): + composer.set_forces_and_torques( + forces=wp.from_numpy(forces_b_np, dtype=wp.vec3f, device=device), + ) + + composer.compose_to_body_frame() + assert np.allclose(composer.out_force_b.numpy(), forces_b_np, atol=1e-4, rtol=1e-5), ( + "Deprecated set_forces_and_torques did not replace previous values" + ) diff --git a/source/isaaclab/test/utils/test_wrench_composer_integration.py b/source/isaaclab/test/utils/test_wrench_composer_integration.py new file mode 100644 index 000000000000..f71690d96541 --- /dev/null +++ b/source/isaaclab/test/utils/test_wrench_composer_integration.py @@ -0,0 +1,817 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Integration tests for wrench composer with rigid objects. + +These tests validate that global forces/torques remain invariant under body rotation +""" + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import pytest +import torch +import warp as wp + +import isaaclab.sim as sim_utils +from isaaclab.assets import RigidObject, RigidObjectCfg +from isaaclab.sim import build_simulation_context +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + + +def generate_cubes_scene( + num_cubes: int = 1, + height: float = 1.0, + device: str = "cuda:0", +) -> tuple[RigidObject, torch.Tensor]: + """Generate a scene with the provided number of cubes.""" + origins = torch.tensor([(i * 1.0, 0, height) for i in range(num_cubes)]).to(device) + for i, origin in enumerate(origins): + sim_utils.create_prim(f"/World/Table_{i}", "Xform", translation=origin) + + spawn_cfg = sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + ) + + cube_object_cfg = RigidObjectCfg( + prim_path="/World/Table_.*/Object", + spawn=spawn_cfg, + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, height)), + ) + cube_object = RigidObject(cfg=cube_object_cfg) + return cube_object, origins + + +N_STEPS = 100 +FORCE_MAGNITUDE = 10.0 +TORQUE_MAGNITUDE = 1.0 + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_force_invariant_under_rotation(device): + """Test that a permanent global force produces the same acceleration before and after body rotation. + + A global +X force is applied. After 100 steps the body is rotated 180deg about Z. + The acceleration (delta_v per phase) should be the same in both phases because the + force is in the global frame and should not rotate with the body. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + mass = float(wp.to_torch(cube_object.root_view.get_masses())[0]) + com = wp.to_torch(cube_object.data.body_com_pos_w).clone() + + # Apply permanent global force along +X at CoM + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=com, + body_ids=body_ids, + is_global=True, + ) + + # Phase 1: run N_STEPS + for _ in range(N_STEPS): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + vel_after_phase1 = wp.to_torch(cube_object.data.root_lin_vel_w)[0].clone() + + # Rotate body 180deg about Z (quat wxyz = [0, 0, 0, 1]) while keeping velocity + root_pose = wp.to_torch(cube_object.data.root_state_w)[0, :7].clone().unsqueeze(0) + root_pose[0, 3:7] = torch.tensor([0.0, 0.0, 1.0, 0.0], device=device) # 180deg about Z (xyzw) + cube_object.write_root_pose_to_sim(root_pose) + + # Phase 2: run N_STEPS more + for _ in range(N_STEPS): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + vel_after_phase2 = wp.to_torch(cube_object.data.root_lin_vel_w)[0].clone() + + # Acceleration should be same in both phases: delta_v_phase2 ≈ delta_v_phase1 + delta_v_phase1 = vel_after_phase1[0].item() # vx after phase 1 + delta_v_phase2 = vel_after_phase2[0].item() - vel_after_phase1[0].item() # vx gained in phase 2 + + expected_dv = FORCE_MAGNITUDE / mass * sim.cfg.dt * N_STEPS + + torch.testing.assert_close( + torch.tensor(delta_v_phase1), + torch.tensor(expected_dv), + rtol=0.001, + atol=0.0001, + ) + torch.testing.assert_close( + torch.tensor(delta_v_phase2), + torch.tensor(expected_dv), + rtol=0.001, + atol=0.0001, + ) + + # Y and Z velocity should remain ~0 + assert abs(vel_after_phase2[1].item()) < 0.5, f"Unexpected Y velocity: {vel_after_phase2[1].item()}" + assert abs(vel_after_phase2[2].item()) < 0.5, f"Unexpected Z velocity: {vel_after_phase2[2].item()}" + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_local_force_follows_rotation(device): + """Test that a permanent local force rotates with the body. + + A local +X force is applied. After 100 steps the body is rotated 180deg about Z. + Since local +X is now world -X, the force should decelerate the body back towards zero velocity. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Apply permanent local force along body +X + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=False, + ) + + # Phase 1: run N_STEPS — object accelerates along world +X + for _ in range(N_STEPS): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + vel_after_phase1 = wp.to_torch(cube_object.data.root_lin_vel_w)[0].clone() + assert vel_after_phase1[0].item() > 1.0, "Object should be moving in +X" + + # Rotate body 180deg about Z while keeping velocity + root_pose = wp.to_torch(cube_object.data.root_state_w)[0, :7].clone().unsqueeze(0) + root_pose[0, 3:7] = torch.tensor([0.0, 0.0, 1.0, 0.0], device=device) # 180deg about Z (xyzw) + cube_object.write_root_pose_to_sim(root_pose) + + # Phase 2: run N_STEPS — local +X is now world -X, so force decelerates + for _ in range(N_STEPS): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + vel_after_phase2 = wp.to_torch(cube_object.data.root_lin_vel_w)[0].clone() + + # Velocity should be approximately zero: decelerated by the same amount as it accelerated + torch.testing.assert_close( + vel_after_phase2[0], + torch.tensor(0.0, device=device), + atol=0.0001, + rtol=0.001, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_force_at_offset_generates_torque(device): + """Test that a global force applied at an offset from CoM generates the expected torque. + + A global +X force applied at +1m Y offset from CoM should produce: + - Linear acceleration in +X + - Angular acceleration about -Z (from cross product: (0,1,0) × (10,0,0) = (0,0,-10)) + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Force at offset: +1m in Y from CoM (global frame) + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE # +X force + + torques = torch.zeros(1, len(body_ids), 3, device=device) + + # Position offset: CoM position + 1m in Y (global frame) + com_pos = wp.to_torch(cube_object.data.body_com_pos_w)[:, body_ids, :3].clone() + positions = com_pos.clone() + positions[..., 1] += 1.0 # +1m Y offset + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=positions, + body_ids=body_ids, + is_global=True, + ) + + # Run 50 steps + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + lin_vel = wp.to_torch(cube_object.data.root_lin_vel_w)[0] + ang_vel = wp.to_torch(cube_object.data.root_ang_vel_w)[0] + + # Linear velocity in +X should be positive + assert lin_vel[0].item() > 0.1, f"Expected positive X velocity, got {lin_vel[0].item()}" + + # Angular velocity about Z should be negative (cross product: r × F, r=(0,1,0), F=(10,0,0) -> (0,0,-10)) + assert ang_vel[2].item() < -0.1, f"Expected negative Z angular velocity, got {ang_vel[2].item()}" + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_torque_invariant_under_rotation(device): + """Test that a permanent global torque produces the same angular acceleration before and after rotation. + + A global +Z torque is applied. After 100 steps the body is rotated 90deg about X. + The angular acceleration (delta_omega per phase) about Z should be the same in both phases + because the torque is in the global frame. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Apply permanent global torque about +Z + forces = torch.zeros(1, len(body_ids), 3, device=device) + torques = torch.zeros(1, len(body_ids), 3, device=device) + torques[..., 2] = TORQUE_MAGNITUDE + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=True, + ) + + # Phase 1: run N_STEPS + for _ in range(N_STEPS): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + omega_z_after_phase1 = wp.to_torch(cube_object.data.root_ang_vel_w)[0, 2].clone().item() + + # Rotate body 90deg about X and zero out velocities so phase 2 starts from rest + # (avoids gyroscopic cross-coupling at high omega) + root_pose = wp.to_torch(cube_object.data.root_state_w)[0, :7].clone().unsqueeze(0) + root_pose[0, 3:7] = torch.tensor([0.7071, 0.0, 0.0, 0.7071], device=device) # 90deg about X (xyzw) + cube_object.write_root_pose_to_sim(root_pose) + cube_object.write_root_velocity_to_sim(torch.zeros(1, 6, device=device)) + + # Phase 2: run N_STEPS from rest with different body orientation + for _ in range(N_STEPS): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + omega_z_after_phase2 = wp.to_torch(cube_object.data.root_ang_vel_w)[0, 2].clone().item() + + # Both phases start from rest — angular acceleration about Z should be the same + torch.testing.assert_close( + torch.tensor(omega_z_after_phase1), + torch.tensor(omega_z_after_phase2), + rtol=0.001, + atol=0.0001, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_force_torque_after_translation(device): + """Test that global force torque updates dynamically when the body translates. + + Phase 1: Cube at (1,0,0). Global force F=(0,10,0) applied at explicit position (1,0,0). + stored_torque = cross((1,0,0), (0,10,0)) = (0,0,10) + correction = -cross((1,0,0), (0,10,0)) = (0,0,-10) + net torque = 0 → no rotation, only linear acceleration in +Y. + + Phase 2: Teleport cube to origin (0,0,0), zero velocity, don't re-apply force. + stored_torque = (0,0,10) (unchanged in buffer) + correction = -cross((0,0,0), (0,10,0)) = (0,0,0) + net torque = (0,0,10) → rotation about +Z. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, height=1.0, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Phase 1 setup: Move cube to (1, 0, 1) and apply force at (1, 0, 1) + root_state = wp.to_torch(cube_object.data.root_state_w).clone() + root_state[0, 0] = 1.0 # x = 1 + root_state[0, 1] = 0.0 # y = 0 + root_state[0, 2] = 1.0 # z = 1 + root_state[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity quat (xyzw) + root_state[0, 7:] = 0.0 # zero velocity + cube_object.write_root_state_to_sim(root_state) + + # Step once to let the state settle + sim.step() + cube_object.update(sim.cfg.dt) + + # Get current CoM position for the force application point + com_pos = wp.to_torch(cube_object.data.body_com_pos_w)[:, body_ids, :3].clone() + + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 1] = FORCE_MAGNITUDE # +Y force + torques = torch.zeros(1, len(body_ids), 3, device=device) + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=com_pos, + body_ids=body_ids, + is_global=True, + ) + + # Phase 1: run 50 steps — force at CoM, expect no rotation + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + ang_vel_phase1 = wp.to_torch(cube_object.data.root_ang_vel_w)[0].clone() + lin_vel_phase1 = wp.to_torch(cube_object.data.root_lin_vel_w)[0].clone() + + # Should have linear velocity in +Y + assert lin_vel_phase1[1].item() > 0.1, f"Expected positive Y velocity, got {lin_vel_phase1[1].item()}" + + # Angular velocity should be ~0 (force applied at CoM → no torque) + assert abs(ang_vel_phase1[2].item()) < 0.1, ( + f"Expected ~0 Z angular velocity in phase 1, got {ang_vel_phase1[2].item()}" + ) + + # Phase 2: Teleport cube to origin, zero velocity, don't re-apply force + root_state2 = wp.to_torch(cube_object.data.root_state_w).clone() + root_state2[0, 0] = 0.0 # x = 0 + root_state2[0, 1] = 0.0 + root_state2[0, 2] = 1.0 # z = 1 + root_state2[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + root_state2[0, 7:] = 0.0 # zero velocity + cube_object.write_root_state_to_sim(root_state2) + + # Step once to let state settle + sim.step() + cube_object.update(sim.cfg.dt) + + # Phase 2: run 50 steps — body at origin but stored torque = cross((1,0,1), (0,10,0)) = (-10,0,10) + # correction = -cross((0,0,1), (0,10,0)) = -(0,0,0 - but wait, z=1) + # Actually: stored = cross((com_x,com_y,com_z), (0,10,0)) + # After teleport: correction = -cross(new_pos, F), net torque ≠ 0 since positions differ + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + ang_vel_phase2 = wp.to_torch(cube_object.data.root_ang_vel_w)[0].clone() + + # The X component of position changed from ~1 to ~0, so torque about Z changes. + # stored_torque_z = com_x * Fy = ~1 * 10 = ~10 + # After teleport, correction_z = -new_x * Fy = ~0 * 10 = ~0 + # net torque_z ≈ 10 → positive Z angular velocity + assert ang_vel_phase2[2].item() > 0.5, ( + f"Expected positive Z angular velocity in phase 2, got {ang_vel_phase2[2].item()}" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_force_torque_reverses_on_opposite_side(device): + """Test that dynamic correction produces correct torque sign depending on body position. + + Phase 1: Cube at (-1, 0, 1). Global F=(0, 10, 0) at world point P=(0, 0, 1). + net torque_z = cross(P - link_pos, F)_z = cross((1,0,0), (0,10,0))_z = +10 + → positive Z angular velocity + + Phase 2: Teleport cube to (+1, 0, 1), zero velocity, don't re-apply force. + net torque_z = cross(P - link_pos, F)_z = cross((-1,0,0), (0,10,0))_z = -10 + → negative Z angular velocity + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, height=1.0, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Move cube to (-1, 0, 1) + root_state = wp.to_torch(cube_object.data.root_state_w).clone() + root_state[0, 0] = -1.0 + root_state[0, 1] = 0.0 + root_state[0, 2] = 1.0 + root_state[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + root_state[0, 7:] = 0.0 + cube_object.write_root_state_to_sim(root_state) + sim.step() + cube_object.update(sim.cfg.dt) + + # Apply permanent global F=(0, 10, 0) at world point P=(0, 0, 1) + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 1] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + positions = torch.zeros(1, len(body_ids), 3, device=device) + positions[..., 2] = 1.0 # P = (0, 0, 1) + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=positions, + body_ids=body_ids, + is_global=True, + ) + + # Phase 1: run 50 steps — expect positive Z angular velocity + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + omega_z_phase1 = wp.to_torch(cube_object.data.root_ang_vel_w)[0, 2].item() + assert omega_z_phase1 > 0.1, f"Phase 1: expected positive omega_z, got {omega_z_phase1}" + + # Phase 2: Teleport cube to (+1, 0, 1), zero velocity + root_state2 = wp.to_torch(cube_object.data.root_state_w).clone() + root_state2[0, 0] = 1.0 + root_state2[0, 1] = 0.0 + root_state2[0, 2] = 1.0 + root_state2[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + root_state2[0, 7:] = 0.0 + cube_object.write_root_state_to_sim(root_state2) + sim.step() + cube_object.update(sim.cfg.dt) + + # Phase 2: run 50 steps — expect negative Z angular velocity + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + omega_z_phase2 = wp.to_torch(cube_object.data.root_ang_vel_w)[0, 2].item() + assert omega_z_phase2 < -0.1, f"Phase 2: expected negative omega_z, got {omega_z_phase2}" + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_force_no_position_no_torque(device): + """Test that global force without positions produces no torque (applied at CoM). + + A body at (2, 0, 1) with global F=(0, 10, 0) and no positions should experience + only linear acceleration, no rotation. The force is applied at the body's CoM. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, height=1.0, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Move cube to (2, 0, 1) + root_state = wp.to_torch(cube_object.data.root_state_w).clone() + root_state[0, 0] = 2.0 + root_state[0, 1] = 0.0 + root_state[0, 2] = 1.0 + root_state[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + root_state[0, 7:] = 0.0 + cube_object.write_root_state_to_sim(root_state) + sim.step() + cube_object.update(sim.cfg.dt) + + # Apply global F=(0, 10, 0) WITHOUT positions → force at CoM, no torque + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 1] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=True, + ) + + # Run 50 steps + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + omega_z = wp.to_torch(cube_object.data.root_ang_vel_w)[0, 2].item() + # No positions → force at CoM → zero torque → zero angular velocity + assert abs(omega_z) < 0.01, f"Expected ~zero omega_z for force at CoM, got {omega_z}" + + # Should still have linear acceleration in +Y + lin_vel_y = wp.to_torch(cube_object.data.root_lin_vel_w)[0, 1].item() + assert lin_vel_y > 0.1, f"Expected positive Y velocity from applied force, got {lin_vel_y}" + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_multi_cube_different_torques_from_same_force(device): + """Test kernel indexing across multiple envs with different CoM positions. + + 2 cubes: Cube 0 at (-1, 0, 1), Cube 1 at (+1, 0, 1). + Same global F=(0, 10, 0) at same world point P=(0, 0, 1) to both cubes. + Cube 0: torque_z = cross((1,0,0), (0,10,0))_z = +10 → omega_z > 0 + Cube 1: torque_z = cross((-1,0,0), (0,10,0))_z = -10 → omega_z < 0 + Both have same linear acceleration in +Y. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=2, height=1.0, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Position cubes: Cube 0 at (-1, 0, 1), Cube 1 at (+1, 0, 1) + root_state = wp.to_torch(cube_object.data.root_state_w).clone() + root_state[0, 0] = -1.0 + root_state[0, 1] = 0.0 + root_state[0, 2] = 1.0 + root_state[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + root_state[0, 7:] = 0.0 + + root_state[1, 0] = 1.0 + root_state[1, 1] = 0.0 + root_state[1, 2] = 1.0 + root_state[1, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + root_state[1, 7:] = 0.0 + cube_object.write_root_state_to_sim(root_state) + sim.step() + cube_object.update(sim.cfg.dt) + + # Apply same global F=(0, 10, 0) at P=(0, 0, 1) to both cubes + forces = torch.zeros(2, len(body_ids), 3, device=device) + forces[..., 1] = FORCE_MAGNITUDE + torques = torch.zeros(2, len(body_ids), 3, device=device) + positions = torch.zeros(2, len(body_ids), 3, device=device) + positions[..., 2] = 1.0 # P = (0, 0, 1) + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=positions, + body_ids=body_ids, + is_global=True, + ) + + # Run 50 steps + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + # Cube 0: omega_z > 0 (force point is to the right of CoM) + omega_z_0 = wp.to_torch(cube_object.data.root_ang_vel_w)[0, 2].item() + assert omega_z_0 > 0.1, f"Cube 0: expected positive omega_z, got {omega_z_0}" + + # Cube 1: omega_z < 0 (force point is to the left of CoM) + omega_z_1 = wp.to_torch(cube_object.data.root_ang_vel_w)[1, 2].item() + assert omega_z_1 < -0.1, f"Cube 1: expected negative omega_z, got {omega_z_1}" + + # Both cubes should have same linear velocity in +Y (same force magnitude) + lin_vel_y_0 = wp.to_torch(cube_object.data.root_lin_vel_w)[0, 1].item() + lin_vel_y_1 = wp.to_torch(cube_object.data.root_lin_vel_w)[1, 1].item() + assert abs(lin_vel_y_0 - lin_vel_y_1) < 0.5, ( + f"Both cubes should have similar Y velocity, got {lin_vel_y_0} and {lin_vel_y_1}" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_global_force_torque_far_from_origin(device): + """Test that global force torque correction produces correct physics at large world coordinates. + + Two cubes with identical relative geometry (force offset = (1, 0, 0) from CoM): + Cube 0 at (0, 0, 1) — near origin (reference) + Cube 1 at (2000, 0, 1) — far from origin + + Both get global F=(0, 10, 0) at offset (1, 0, 0) from their respective CoMs. + Expected torque: cross((1,0,0), (0,10,0)) = (0, 0, 10) for both. + + The compose kernel computes cross(P, F) - cross(link_pos, F): + Cube 0: cross((1,0,1), F) - cross((0,0,1), F) — small values, no cancellation + Cube 1: cross((2001,0,1), F) - cross((2000,0,1), F) — large values nearly cancel + + Both cubes should produce the same angular and linear velocities. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=2, height=1.0, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Position cubes: Cube 0 near origin, Cube 1 far from origin + root_state = wp.to_torch(cube_object.data.root_state_w).clone() + # Cube 0 at (0, 0, 1) + root_state[0, 0] = 0.0 + root_state[0, 1] = 0.0 + root_state[0, 2] = 1.0 + root_state[0, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + root_state[0, 7:] = 0.0 + # Cube 1 at (2000, 0, 1) + root_state[1, 0] = 2000.0 + root_state[1, 1] = 0.0 + root_state[1, 2] = 1.0 + root_state[1, 3:7] = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) # identity (xyzw) + root_state[1, 7:] = 0.0 + cube_object.write_root_state_to_sim(root_state) + sim.step() + cube_object.update(sim.cfg.dt) + + # Apply F=(0, 10, 0) at +1m X offset from each cube's CoM + forces = torch.zeros(2, len(body_ids), 3, device=device) + forces[..., 1] = FORCE_MAGNITUDE # +Y force + torques = torch.zeros(2, len(body_ids), 3, device=device) + + # Positions: each cube's CoM + (1, 0, 0) + com_pos = wp.to_torch(cube_object.data.body_com_pos_w)[:, body_ids, :3].clone() + positions = com_pos.clone() + positions[..., 0] += 1.0 # +1m X offset from CoM + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=positions, + body_ids=body_ids, + is_global=True, + ) + + # Run 50 steps + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + # Both cubes should have positive omega_z (cross((1,0,0), (0,10,0)) = (0,0,10)) + omega_z_0 = wp.to_torch(cube_object.data.root_ang_vel_w)[0, 2].item() + omega_z_1 = wp.to_torch(cube_object.data.root_ang_vel_w)[1, 2].item() + assert omega_z_0 > 0.1, f"Cube 0: expected positive omega_z, got {omega_z_0}" + assert omega_z_1 > 0.1, f"Cube 1: expected positive omega_z, got {omega_z_1}" + + # omega_z values should match within 1% (same relative geometry) + torch.testing.assert_close( + torch.tensor(omega_z_0), + torch.tensor(omega_z_1), + rtol=0.01, + atol=0.0, + msg=lambda msg: ( + f"Angular velocity mismatch between near-origin and far-from-origin cubes:\n" + f" Cube 0 (near): omega_z = {omega_z_0:.6f}\n" + f" Cube 1 (far): omega_z = {omega_z_1:.6f}\n{msg}" + ), + ) + + # Linear velocity in +Y should also match + lin_vel_y_0 = wp.to_torch(cube_object.data.root_lin_vel_w)[0, 1].item() + lin_vel_y_1 = wp.to_torch(cube_object.data.root_lin_vel_w)[1, 1].item() + torch.testing.assert_close( + torch.tensor(lin_vel_y_0), + torch.tensor(lin_vel_y_1), + rtol=0.01, + atol=0.0, + msg=lambda msg: ( + f"Linear velocity mismatch between near-origin and far-from-origin cubes:\n" + f" Cube 0 (near): lin_vel_y = {lin_vel_y_0:.6f}\n" + f" Cube 1 (far): lin_vel_y = {lin_vel_y_1:.6f}\n{msg}" + ), + ) + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_global_force_no_position_no_rotation_large_offset(device): + """Test that a global force without positions produces no rotation at large offsets. + + A cube is placed at (2000, 0, 1) and a global force F=(0, 10, 0) is applied + without positions. The cube should accelerate linearly but not rotate. + Before the fix, this would produce torque proportional to 2000 and cause rotation. + """ + with build_simulation_context( + device=device, add_ground_plane=False, auto_add_lighting=True, gravity_enabled=False + ) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, height=1.0, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Place cube at large X offset + root_state = wp.to_torch(cube_object.data.default_root_state).clone() + root_state[0, 0] = 2000.0 # large X position + root_state[0, 1] = 0.0 + root_state[0, 2] = 1.0 + cube_object.write_root_pose_to_sim(root_state[:, :7]) + cube_object.write_root_velocity_to_sim(root_state[:, 7:]) + cube_object.reset() + + # Apply global force without positions (should go to CoM, no torque) + forces = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=device) + forces[0, :, 1] = 10.0 # F_y = 10 N + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + body_ids=body_ids, + is_global=True, + ) + + # Step simulation + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + # Check: angular velocity should be near zero (no rotation) + ang_vel = wp.to_torch(cube_object.data.root_ang_vel_w)[0] + assert torch.allclose(ang_vel, torch.zeros(3, device=device), atol=0.01), ( + f"Expected near-zero angular velocity, got {ang_vel}. " + "Global force without positions should not produce torque." + ) + + # Check: linear velocity in Y should be positive (force is in +Y) + lin_vel = wp.to_torch(cube_object.data.root_lin_vel_w)[0] + assert lin_vel[1] > 0.1, f"Expected positive Y velocity from applied force, got {lin_vel[1]}" + + +@pytest.mark.parametrize("device", ["cuda:0"]) +def test_global_force_at_com_position_no_rotation_large_offset(device): + """Test that a global force with position at CoM produces no rotation at large offsets. + + A cube is placed at (2000, 0, 1) and a global force F=(0, 10, 0) is applied + at the cube's position (i.e., at its CoM). This should produce zero torque, + serving as a control test alongside test_global_force_no_position_no_rotation_large_offset. + """ + with build_simulation_context( + device=device, add_ground_plane=False, auto_add_lighting=True, gravity_enabled=False + ) as sim: + sim._app_control_on_stop_handle = None + cube_object, _ = generate_cubes_scene(num_cubes=1, height=1.0, device=device) + + sim.reset() + + body_ids, _ = cube_object.find_bodies(".*") + + # Place cube at large X offset + root_state = wp.to_torch(cube_object.data.default_root_state).clone() + root_state[0, 0] = 2000.0 + root_state[0, 1] = 0.0 + root_state[0, 2] = 1.0 + cube_object.write_root_pose_to_sim(root_state[:, :7]) + cube_object.write_root_velocity_to_sim(root_state[:, 7:]) + cube_object.reset() + + # Apply global force AT the cube's position (torque should cancel) + forces = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=device) + forces[0, :, 1] = 10.0 + + positions = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=device) + positions[0, :, 0] = 2000.0 + positions[0, :, 2] = 1.0 + + cube_object.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + positions=positions, + body_ids=body_ids, + is_global=True, + ) + + for _ in range(50): + cube_object.write_data_to_sim() + sim.step() + cube_object.update(sim.cfg.dt) + + # Force at CoM → no rotation + ang_vel = wp.to_torch(cube_object.data.root_ang_vel_w)[0] + assert torch.allclose(ang_vel, torch.zeros(3, device=device), atol=0.01), ( + f"Expected near-zero angular velocity, got {ang_vel}. " + "Global force at CoM position should not produce torque." + ) + + lin_vel = wp.to_torch(cube_object.data.root_lin_vel_w)[0] + assert lin_vel[1] > 0.1, f"Expected positive Y velocity from applied force, got {lin_vel[1]}" diff --git a/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py new file mode 100644 index 000000000000..bb0a69132890 --- /dev/null +++ b/source/isaaclab/test/utils/test_wrench_composer_vs_physx.py @@ -0,0 +1,837 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Integration tests comparing WrenchComposer output vs raw PhysX apply_forces_and_torques_at_position. + +Two identical rigid objects are placed in the same scene. One uses the WrenchComposer path +(set_forces_and_torques → write_data_to_sim → compose → PhysX apply with is_global=False), +the other uses the raw PhysX API directly (apply_forces_and_torques_at_position with matching +is_global flag). After N steps, both objects should have identical velocities. +""" + +"""Launch Isaac Sim Simulator first.""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +"""Rest everything follows.""" + +import math + +import pytest +import torch +import warp as wp + +import isaaclab.sim as sim_utils +from isaaclab.assets import RigidObject, RigidObjectCfg +from isaaclab.sim import build_simulation_context +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR + + +def generate_dual_cube_scene( + num_cubes: int = 1, + height: float = 1.0, + device: str = "cuda:0", + initial_rot: tuple[float, ...] | None = None, + spacing: float = 2.0, +) -> tuple[RigidObject, RigidObject]: + """Generate a scene with two sets of cubes: one for the composer path, one for raw PhysX. + + Both sets share the same spawn config and initial state (except a Y offset to avoid overlap). + + Args: + num_cubes: Number of cubes per group (environments). + height: Spawn height. + device: Simulation device. + initial_rot: Initial quaternion (x, y, z, w). Defaults to identity. + spacing: Distance between env origins in X. Defaults to 2.0. + + Returns: + Tuple of (cube_composer, cube_raw) RigidObject instances. + """ + if initial_rot is None: + initial_rot = (0.0, 0.0, 0.0, 1.0) # identity in (x,y,z,w) + + y_offset = max(spacing, 3.0) + + # Create Xform prims for both groups + for i in range(num_cubes): + origin_composer = (i * spacing, 0.0, height) + origin_raw = (i * spacing, y_offset, height) # Y offset to avoid overlap + sim_utils.create_prim(f"/World/Composer_{i}", "Xform", translation=origin_composer) + sim_utils.create_prim(f"/World/Raw_{i}", "Xform", translation=origin_raw) + + spawn_cfg = sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Blocks/DexCube/dex_cube_instanceable.usd", + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + ) + + cube_composer_cfg = RigidObjectCfg( + prim_path="/World/Composer_.*/Object", + spawn=spawn_cfg, + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, height), rot=initial_rot), + ) + cube_composer = RigidObject(cfg=cube_composer_cfg) + + cube_raw_cfg = RigidObjectCfg( + prim_path="/World/Raw_.*/Object", + spawn=spawn_cfg, + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, y_offset, height), rot=initial_rot), + ) + cube_raw = RigidObject(cfg=cube_raw_cfg) + + return cube_composer, cube_raw + + +N_STEPS = 50 +FORCE_MAGNITUDE = 10.0 +TORQUE_MAGNITUDE = 1.0 +# 45 degrees about Z: (cos(22.5°), 0, 0, sin(22.5°)) +ROT_45_Z = (0.0, 0.0, math.sin(math.pi / 8), math.cos(math.pi / 8)) # 45deg about Z in (x,y,z,w) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_local_force(device): + """Baseline: local force at identity orientation. Composer and raw PhysX should match exactly.""" + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene(num_cubes=1, device=device) + + sim.reset() + + body_ids, _ = cube_composer.find_bodies(".*") + + # Composer path: local force +X + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=False, + ) + + # Raw PhysX data (flattened for PhysX view API) + raw_forces = torch.zeros(1, 3, device=device) + raw_forces[:, 0] = FORCE_MAGNITUDE + raw_torques = torch.zeros(1, 3, device=device) + raw_indices = cube_raw._ALL_INDICES + + for _ in range(N_STEPS): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() # no-op (composer inactive) + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=None, + indices=raw_indices, + is_global=False, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Compare velocities + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + wp.to_torch(cube_raw.data.root_lin_vel_w), + rtol=1e-4, + atol=1e-4, + ) + # Both should have ~zero angular velocity (force at CoM, no torque) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + torch.zeros(1, 3, device=device), + rtol=0.0, + atol=1e-4, + ) + torch.testing.assert_close( + wp.to_torch(cube_raw.data.root_ang_vel_w), + torch.zeros(1, 3, device=device), + rtol=0.0, + atol=1e-4, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_global_force(device): + """Global force with non-identity rotation (45 deg Z). Rotation matters for frame conversion.""" + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene(num_cubes=1, device=device, initial_rot=ROT_45_Z) + + sim.reset() + + body_ids, _ = cube_composer.find_bodies(".*") + + # Composer path: global force +X + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=True, + ) + + # Raw PhysX data + raw_forces = torch.zeros(1, 3, device=device) + raw_forces[:, 0] = FORCE_MAGNITUDE + raw_torques = torch.zeros(1, 3, device=device) + raw_indices = cube_raw._ALL_INDICES + + for _ in range(N_STEPS): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=None, + indices=raw_indices, + is_global=True, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Linear velocities should match (same global force, same mass) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + wp.to_torch(cube_raw.data.root_lin_vel_w), + rtol=1e-4, + atol=1e-4, + ) + # Angular velocities should match + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-4, + atol=1e-4, + ) + # Both should have ~zero angular velocity (force at CoM, no torque) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + torch.zeros(1, 3, device=device), + rtol=0.0, + atol=1e-4, + ) + torch.testing.assert_close( + wp.to_torch(cube_raw.data.root_ang_vel_w), + torch.zeros(1, 3, device=device), + rtol=0.0, + atol=1e-4, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_local_force_at_position(device): + """Local force at a local offset. Both paths should produce identical cross-product torque.""" + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene(num_cubes=1, device=device) + + sim.reset() + + body_ids, _ = cube_composer.find_bodies(".*") + + # Local force +X at local offset +0.5m Y + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + positions = torch.zeros(1, len(body_ids), 3, device=device) + positions[..., 1] = 0.5 # +0.5m Y offset in local frame + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=positions, + body_ids=body_ids, + is_global=False, + ) + + # Raw PhysX data (local force at local position) + raw_forces = torch.zeros(1, 3, device=device) + raw_forces[:, 0] = FORCE_MAGNITUDE + raw_torques = torch.zeros(1, 3, device=device) + raw_positions = torch.zeros(1, 3, device=device) + raw_positions[:, 1] = 0.5 + raw_indices = cube_raw._ALL_INDICES + + for _ in range(N_STEPS): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=wp.from_torch(raw_positions.contiguous(), dtype=wp.float32), + indices=raw_indices, + is_global=False, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Both linear and angular velocities should match + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + wp.to_torch(cube_raw.data.root_lin_vel_w), + rtol=1e-4, + atol=1e-4, + ) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-4, + atol=1e-4, + ) + + # Sanity: angular velocity should be nonzero (cross-product torque) + assert torch.abs(wp.to_torch(cube_composer.data.root_ang_vel_w)[0, 2]).item() > 0.1, ( + "Expected nonzero Z angular velocity from cross-product torque" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_global_force_at_position(device): + """Global force at world position with non-identity rotation. Both rotation AND position correction matter.""" + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene(num_cubes=1, device=device, initial_rot=ROT_45_Z) + + sim.reset() + + body_ids, _ = cube_composer.find_bodies(".*") + + # Global force +X + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + + # Position = each cube's link_pos + offset (same offset for both) + offset = torch.zeros(1, len(body_ids), 3, device=device) + offset[..., 1] = 1.0 # +1m Y offset in world frame + + pos_composer = wp.to_torch(cube_composer.data.body_com_pos_w)[:, body_ids, :3].clone() + offset + pos_raw = wp.to_torch(cube_raw.data.body_com_pos_w)[:, body_ids, :3].clone() + offset + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=pos_composer, + body_ids=body_ids, + is_global=True, + ) + + # Raw PhysX data + raw_forces = torch.zeros(1, 3, device=device) + raw_forces[:, 0] = FORCE_MAGNITUDE + raw_torques = torch.zeros(1, 3, device=device) + raw_positions = pos_raw.view(-1, 3) + raw_indices = cube_raw._ALL_INDICES + + for _ in range(N_STEPS): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=wp.from_torch(raw_positions.contiguous(), dtype=wp.float32), + indices=raw_indices, + is_global=True, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Both linear and angular velocities should match + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + wp.to_torch(cube_raw.data.root_lin_vel_w), + rtol=1e-4, + atol=1e-4, + ) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-4, + atol=1e-4, + ) + + # Sanity: angular velocity should be nonzero (cross-product torque) + assert torch.abs(wp.to_torch(cube_composer.data.root_ang_vel_w)[0, 2]).item() > 0.1, ( + "Expected nonzero Z angular velocity from positional torque" + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_local_torque(device): + """Local torque at identity orientation. Should produce matching angular velocity.""" + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene(num_cubes=1, device=device) + + sim.reset() + + body_ids, _ = cube_composer.find_bodies(".*") + + # Composer path: local torque about +Z + forces = torch.zeros(1, len(body_ids), 3, device=device) + torques = torch.zeros(1, len(body_ids), 3, device=device) + torques[..., 2] = TORQUE_MAGNITUDE + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=False, + ) + + # Raw PhysX data + raw_forces = torch.zeros(1, 3, device=device) + raw_torques = torch.zeros(1, 3, device=device) + raw_torques[:, 2] = TORQUE_MAGNITUDE + raw_indices = cube_raw._ALL_INDICES + + for _ in range(N_STEPS): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=None, + indices=raw_indices, + is_global=False, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Angular velocities should match + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-4, + atol=1e-4, + ) + # Linear velocity should be ~zero for both (no force) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + torch.zeros(1, 3, device=device), + rtol=0.0, + atol=1e-4, + ) + torch.testing.assert_close( + wp.to_torch(cube_raw.data.root_lin_vel_w), + torch.zeros(1, 3, device=device), + rtol=0.0, + atol=1e-4, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_global_torque(device): + """Global torque with non-identity rotation (45 deg Z). Composer rotates to body frame internally.""" + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene(num_cubes=1, device=device, initial_rot=ROT_45_Z) + + sim.reset() + + body_ids, _ = cube_composer.find_bodies(".*") + + # Composer path: global torque about +Z + forces = torch.zeros(1, len(body_ids), 3, device=device) + torques = torch.zeros(1, len(body_ids), 3, device=device) + torques[..., 2] = TORQUE_MAGNITUDE + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=True, + ) + + # Raw PhysX data + raw_forces = torch.zeros(1, 3, device=device) + raw_torques = torch.zeros(1, 3, device=device) + raw_torques[:, 2] = TORQUE_MAGNITUDE + raw_indices = cube_raw._ALL_INDICES + + for _ in range(N_STEPS): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=None, + indices=raw_indices, + is_global=True, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Angular velocities should match + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-4, + atol=1e-4, + ) + + +NUM_CUBES_MULTI = 4 + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_global_force_multi_env(device): + """Global force (no position) with multiple environments. + + Regression: checks that env-indexing and per-body quaternion handling work correctly + when there is more than one environment. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene( + num_cubes=NUM_CUBES_MULTI, device=device, initial_rot=ROT_45_Z + ) + + sim.reset() + + body_ids, _ = cube_composer.find_bodies(".*") + + # Composer path: global force +X for all envs + forces = torch.zeros(NUM_CUBES_MULTI, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE + torques = torch.zeros(NUM_CUBES_MULTI, len(body_ids), 3, device=device) + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=True, + ) + + # Raw PhysX data (one row per env) + raw_forces = torch.zeros(NUM_CUBES_MULTI, 3, device=device) + raw_forces[:, 0] = FORCE_MAGNITUDE + raw_torques = torch.zeros(NUM_CUBES_MULTI, 3, device=device) + raw_indices = cube_raw._ALL_INDICES + + for _ in range(N_STEPS): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=None, + indices=raw_indices, + is_global=True, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Linear velocities should match across all envs + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + wp.to_torch(cube_raw.data.root_lin_vel_w), + rtol=1e-4, + atol=1e-4, + ) + # Angular velocities should match + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-4, + atol=1e-4, + ) + # All envs should have ~zero angular velocity + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + torch.zeros(NUM_CUBES_MULTI, 3, device=device), + rtol=0.0, + atol=1e-4, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_global_force_with_reset(device): + """Global force (no position) with a mid-simulation reset of half the envs. + + Regression: after reset the permanent wrench is cleared. Re-setting it should + produce correct behavior even though the object state was just reset. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene( + num_cubes=NUM_CUBES_MULTI, device=device, initial_rot=ROT_45_Z, spacing=20.0 + ) + + sim.reset() + + # Capture initial world-frame state (includes env origin offsets) + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + initial_state_composer = torch.cat( + [ + wp.to_torch(cube_composer.data.root_link_pos_w), + wp.to_torch(cube_composer.data.root_link_quat_w), + wp.to_torch(cube_composer.data.root_com_vel_w), + ], + dim=-1, + ).clone() + initial_state_raw = torch.cat( + [ + wp.to_torch(cube_raw.data.root_link_pos_w), + wp.to_torch(cube_raw.data.root_link_quat_w), + wp.to_torch(cube_raw.data.root_com_vel_w), + ], + dim=-1, + ).clone() + + body_ids, _ = cube_composer.find_bodies(".*") + + def apply_global_force(): + """Set the same global +X force on the composer cube.""" + forces = torch.zeros(NUM_CUBES_MULTI, len(body_ids), 3, device=device) + forces[..., 0] = FORCE_MAGNITUDE + torques = torch.zeros(NUM_CUBES_MULTI, len(body_ids), 3, device=device) + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=True, + ) + + apply_global_force() + + # Raw PhysX data + raw_forces = torch.zeros(NUM_CUBES_MULTI, 3, device=device) + raw_forces[:, 0] = FORCE_MAGNITUDE + raw_torques = torch.zeros(NUM_CUBES_MULTI, 3, device=device) + raw_indices = cube_raw._ALL_INDICES + + # Phase 1: run N_STEPS / 2 + half = N_STEPS // 2 + for _ in range(half): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=None, + indices=raw_indices, + is_global=True, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Reset first half of envs on both cubes + reset_ids = list(range(NUM_CUBES_MULTI // 2)) + reset_ids_torch = torch.tensor(reset_ids, dtype=torch.long, device=device) + + # Reset root state using captured world-frame initial state (includes env origins) + cube_composer.write_root_state_to_sim(initial_state_composer[reset_ids_torch], env_ids=reset_ids_torch) + cube_raw.write_root_state_to_sim(initial_state_raw[reset_ids_torch], env_ids=reset_ids_torch) + + cube_composer.reset(reset_ids) + cube_raw.reset(reset_ids) + + # Re-apply the force (reset cleared the permanent wrench) + apply_global_force() + + # Phase 2: run N_STEPS / 2 more + for _ in range(half): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=None, + indices=raw_indices, + is_global=True, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # All envs: composer vs raw should match + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + wp.to_torch(cube_raw.data.root_lin_vel_w), + rtol=1e-4, + atol=1e-4, + ) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-4, + atol=1e-4, + ) + # All envs should have ~zero angular velocity + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + torch.zeros(NUM_CUBES_MULTI, 3, device=device), + rtol=0.0, + atol=1e-4, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_payload_scenario(device): + """Mirrors the apply_payload MDP: permanent global downward force at CoM with gravity. + + A constant world-frame downward force (payload weight) is applied via the composer + path vs raw PhysX. The body falls under gravity + payload, contacts the ground, and + orientation changes. The composer does a world->body->world round-trip each step; + this test catches any precision drift from that. + """ + with build_simulation_context(device=device, gravity_enabled=True, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene( + num_cubes=1, height=0.5, device=device, initial_rot=ROT_45_Z, spacing=20.0 + ) + + sim.reset() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Record initial positions to compare displacements (cubes spawn at different Y) + init_pos_composer = wp.to_torch(cube_composer.data.root_pos_w).clone() + init_pos_raw = wp.to_torch(cube_raw.data.root_pos_w).clone() + + body_ids, _ = cube_composer.find_bodies(".*") + + payload_force = 2.0 * 9.81 + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 2] = -payload_force + torques = torch.zeros(1, len(body_ids), 3, device=device) + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + body_ids=body_ids, + is_global=True, + ) + + raw_forces = torch.zeros(1, 3, device=device) + raw_forces[:, 2] = -payload_force + raw_torques = torch.zeros(1, 3, device=device) + raw_indices = cube_raw._ALL_INDICES + + for _ in range(N_STEPS): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=None, + indices=raw_indices, + is_global=True, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + # Compare displacements (not absolute positions — cubes have different spawn Y) + disp_composer = wp.to_torch(cube_composer.data.root_pos_w) - init_pos_composer + disp_raw = wp.to_torch(cube_raw.data.root_pos_w) - init_pos_raw + + torch.testing.assert_close(disp_composer, disp_raw, rtol=1e-4, atol=1e-4) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + wp.to_torch(cube_raw.data.root_lin_vel_w), + rtol=1e-4, + atol=1e-4, + ) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-4, + atol=1e-4, + ) + + +@pytest.mark.parametrize("device", ["cuda:0", "cpu"]) +def test_composer_vs_physx_permanent_global_force_at_position_long_run(device): + """Permanent global force at a world-frame offset, run long enough for significant body motion. + + This test catches temporal drift bugs where the stored positional torque diverges from + what PhysX computes each step as the body moves. The force is large enough that the body + translates and rotates significantly over 100 steps, but not so large that it causes + numerical instability. + """ + with build_simulation_context(device=device, gravity_enabled=False, auto_add_lighting=True) as sim: + sim._app_control_on_stop_handle = None + cube_composer, cube_raw = generate_dual_cube_scene(num_cubes=1, device=device, initial_rot=ROT_45_Z) + + sim.reset() + + body_ids, _ = cube_composer.find_bodies(".*") + + # Global force +Z at +1m Y offset from CoM — produces torque around X + forces = torch.zeros(1, len(body_ids), 3, device=device) + forces[..., 2] = FORCE_MAGNITUDE + torques = torch.zeros(1, len(body_ids), 3, device=device) + + offset = torch.zeros(1, len(body_ids), 3, device=device) + offset[..., 1] = 1.0 + + pos_composer = wp.to_torch(cube_composer.data.body_com_pos_w)[:, body_ids, :3].clone() + offset + pos_raw = wp.to_torch(cube_raw.data.body_com_pos_w)[:, body_ids, :3].clone() + offset + + cube_composer.permanent_wrench_composer.set_forces_and_torques( + forces=forces, + torques=torques, + positions=pos_composer, + body_ids=body_ids, + is_global=True, + ) + + raw_forces = torch.zeros(1, 3, device=device) + raw_forces[:, 2] = FORCE_MAGNITUDE + raw_torques = torch.zeros(1, 3, device=device) + raw_positions = pos_raw.view(-1, 3) + raw_indices = cube_raw._ALL_INDICES + + for _ in range(100): + cube_composer.write_data_to_sim() + cube_raw.write_data_to_sim() + cube_raw.root_view.apply_forces_and_torques_at_position( + force_data=wp.from_torch(raw_forces.contiguous(), dtype=wp.float32), + torque_data=wp.from_torch(raw_torques.contiguous(), dtype=wp.float32), + position_data=wp.from_torch(raw_positions.contiguous(), dtype=wp.float32), + indices=raw_indices, + is_global=True, + ) + sim.step() + cube_composer.update(sim.cfg.dt) + cube_raw.update(sim.cfg.dt) + + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_lin_vel_w), + wp.to_torch(cube_raw.data.root_lin_vel_w), + rtol=1e-3, + atol=1e-3, + ) + torch.testing.assert_close( + wp.to_torch(cube_composer.data.root_ang_vel_w), + wp.to_torch(cube_raw.data.root_ang_vel_w), + rtol=1e-3, + atol=1e-3, + ) + + # Sanity: angular velocity should be nonzero + assert torch.abs(wp.to_torch(cube_composer.data.root_ang_vel_w)).max().item() > 0.1, ( + "Expected nonzero angular velocity from positional torque over 100 steps" + ) diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 810d1e6174f3..3639311a8a91 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.18" +version = "0.5.19" # Description title = "Newton simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 93f40d2a148e..f8f670baf455 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.5.19 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Updated ``write_data_to_sim`` in :class:`~isaaclab_newton.assets.Articulation`, + :class:`~isaaclab_newton.assets.RigidObject`, and :class:`~isaaclab_newton.assets.RigidObjectCollection` + to use the dual-buffer :class:`~isaaclab.utils.wrench_composer.WrenchComposer`. Composed wrenches are + applied after body-frame composition. + + 0.5.18 (2026-04-21) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index 9d62dc0bbed1..515176352490 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -256,40 +256,23 @@ def write_data_to_sim(self): # write external wrench if self._instantaneous_wrench_composer.active or self._permanent_wrench_composer.active: if self._instantaneous_wrench_composer.active: - # Compose instantaneous wrench with permanent wrench - self._instantaneous_wrench_composer.add_forces_and_torques_index( - forces=self._permanent_wrench_composer.composed_force, - torques=self._permanent_wrench_composer.composed_torque, - body_ids=self._ALL_BODY_INDICES, - env_ids=self._ALL_INDICES, - ) - # Apply both instantaneous and permanent wrench to the simulation - wp.launch( - shared_kernels.update_wrench_array_with_force_and_torque, - dim=(self.num_instances, self.num_bodies), - device=self.device, - inputs=[ - self._instantaneous_wrench_composer.composed_force, - self._instantaneous_wrench_composer.composed_torque, - self._data._sim_bind_body_external_wrench, - self._ALL_ENV_MASK, - self._ALL_BODY_MASK, - ], - ) + composer = self._instantaneous_wrench_composer + composer.add_raw_buffers_from(self._permanent_wrench_composer) else: - # Apply permanent wrench to the simulation - wp.launch( - shared_kernels.update_wrench_array_with_force_and_torque, - dim=(self.num_instances, self.num_bodies), - device=self.device, - inputs=[ - self._permanent_wrench_composer.composed_force, - self._permanent_wrench_composer.composed_torque, - self._data._sim_bind_body_external_wrench, - self._ALL_ENV_MASK, - self._ALL_BODY_MASK, - ], - ) + composer = self._permanent_wrench_composer + composer.compose_to_body_frame() + wp.launch( + shared_kernels.update_wrench_array_with_force_and_torque, + dim=(self.num_instances, self.num_bodies), + device=self.device, + inputs=[ + composer.out_force_b, + composer.out_torque_b, + self._data._sim_bind_body_external_wrench, + self._ALL_ENV_MASK, + self._ALL_BODY_MASK, + ], + ) self._instantaneous_wrench_composer.reset() # apply actuator models diff --git a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object.py b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object.py index 5e02e3622985..fb2d29091203 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object/rigid_object.py @@ -143,40 +143,23 @@ def write_data_to_sim(self) -> None: # write external wrench if self._instantaneous_wrench_composer.active or self._permanent_wrench_composer.active: if self._instantaneous_wrench_composer.active: - # Compose instantaneous wrench with permanent wrench - self._instantaneous_wrench_composer.add_forces_and_torques_index( - forces=self._permanent_wrench_composer.composed_force, - torques=self._permanent_wrench_composer.composed_torque, - body_ids=self._ALL_BODY_INDICES, - env_ids=self._ALL_INDICES, - ) - # Apply both instantaneous and permanent wrench to the simulation - wp.launch( - shared_kernels.update_wrench_array_with_force_and_torque, - dim=(self.num_instances, self.num_bodies), - device=self.device, - inputs=[ - self._instantaneous_wrench_composer.composed_force, - self._instantaneous_wrench_composer.composed_torque, - self._data._sim_bind_body_external_wrench, - self._ALL_ENV_MASK, - self._ALL_BODY_MASK, - ], - ) + composer = self._instantaneous_wrench_composer + composer.add_raw_buffers_from(self._permanent_wrench_composer) else: - # Apply permanent wrench to the simulation - wp.launch( - shared_kernels.update_wrench_array_with_force_and_torque, - dim=(self.num_instances, self.num_bodies), - device=self.device, - inputs=[ - self._permanent_wrench_composer.composed_force, - self._permanent_wrench_composer.composed_torque, - self._data._sim_bind_body_external_wrench, - self._ALL_ENV_MASK, - self._ALL_BODY_MASK, - ], - ) + composer = self._permanent_wrench_composer + composer.compose_to_body_frame() + wp.launch( + shared_kernels.update_wrench_array_with_force_and_torque, + dim=(self.num_instances, self.num_bodies), + device=self.device, + inputs=[ + composer.out_force_b, + composer.out_torque_b, + self._data._sim_bind_body_external_wrench, + self._ALL_ENV_MASK, + self._ALL_BODY_MASK, + ], + ) self._instantaneous_wrench_composer.reset() def update(self, dt: float) -> None: diff --git a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection.py index 82bdf7a03003..52216290d329 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/rigid_object_collection/rigid_object_collection.py @@ -187,40 +187,23 @@ def write_data_to_sim(self) -> None: # write external wrench if self._instantaneous_wrench_composer.active or self._permanent_wrench_composer.active: if self._instantaneous_wrench_composer.active: - # Compose instantaneous wrench with permanent wrench - self._instantaneous_wrench_composer.add_forces_and_torques_index( - forces=self._permanent_wrench_composer.composed_force, - torques=self._permanent_wrench_composer.composed_torque, - body_ids=self._ALL_BODY_INDICES, - env_ids=self._ALL_ENV_INDICES, - ) - # Apply both instantaneous and permanent wrench to a consolidated 2D buffer - wp.launch( - shared_kernels.update_wrench_array_with_force_and_torque, - dim=(self.num_instances, self.num_bodies), - device=self.device, - inputs=[ - self._instantaneous_wrench_composer.composed_force, - self._instantaneous_wrench_composer.composed_torque, - self._wrench_buffer, - self._ALL_ENV_MASK, - self._ALL_BODY_MASK, - ], - ) + composer = self._instantaneous_wrench_composer + composer.add_raw_buffers_from(self._permanent_wrench_composer) else: - # Apply permanent wrench to a consolidated 2D buffer - wp.launch( - shared_kernels.update_wrench_array_with_force_and_torque, - dim=(self.num_instances, self.num_bodies), - device=self.device, - inputs=[ - self._permanent_wrench_composer.composed_force, - self._permanent_wrench_composer.composed_torque, - self._wrench_buffer, - self._ALL_ENV_MASK, - self._ALL_BODY_MASK, - ], - ) + composer = self._permanent_wrench_composer + composer.compose_to_body_frame() + wp.launch( + shared_kernels.update_wrench_array_with_force_and_torque, + dim=(self.num_instances, self.num_bodies), + device=self.device, + inputs=[ + composer.out_force_b, + composer.out_torque_b, + self._wrench_buffer, + self._ALL_ENV_MASK, + self._ALL_BODY_MASK, + ], + ) # Write the wrench buffer directly to the Newton binding (already 2D) wp.copy(self._data._sim_bind_body_external_wrench, self._wrench_buffer) self._instantaneous_wrench_composer.reset() diff --git a/source/isaaclab_newton/test/assets/test_rigid_object.py b/source/isaaclab_newton/test/assets/test_rigid_object.py index c1d01f4164fb..138b23b9fb4b 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object.py @@ -372,7 +372,6 @@ def test_external_force_on_single_body(num_cubes, device): assert torch.all(wp.to_torch(cube_object.data.root_pos_w)[1::2, 2] < 1.0) -@pytest.mark.skip(reason="Newton wrench composer at-position force composition differs from PhysX") @pytest.mark.parametrize("num_cubes", [2, 4]) @pytest.mark.parametrize("device", ["cuda:0", "cpu"]) def test_external_force_on_single_body_at_position(num_cubes, device): @@ -399,14 +398,9 @@ def test_external_force_on_single_body_at_position(num_cubes, device): external_wrench_b = torch.zeros(cube_object.num_instances, len(body_ids), 6, device=sim.device) external_wrench_positions_b = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=sim.device) # Every 2nd cube should have a force applied to it - external_wrench_b[0::2, :, 2] = 500.0 + external_wrench_b[0::2, :, 2] = 50.0 external_wrench_positions_b[0::2, :, 1] = 1.0 - # Desired force and torque - desired_force = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=sim.device) - desired_force[0::2, :, 2] = 1000.0 - desired_torque = torch.zeros(cube_object.num_instances, len(body_ids), 3, device=sim.device) - desired_torque[0::2, :, 0] = 1000.0 # Now we are ready! for i in range(5): # reset root state @@ -449,18 +443,6 @@ def test_external_force_on_single_body_at_position(num_cubes, device): body_ids=body_ids, is_global=is_global, ) - torch.testing.assert_close( - wp.to_torch(cube_object._permanent_wrench_composer.composed_force)[:, 0, :], - desired_force[:, 0, :], - rtol=1e-6, - atol=1e-7, - ) - torch.testing.assert_close( - wp.to_torch(cube_object._permanent_wrench_composer.composed_torque)[:, 0, :], - desired_torque[:, 0, :], - rtol=1e-6, - atol=1e-7, - ) # perform simulation for _ in range(5): # apply action to the object diff --git a/source/isaaclab_newton/test/assets/test_rigid_object_collection.py b/source/isaaclab_newton/test/assets/test_rigid_object_collection.py index 7d4a0be7cb98..4c5599e35887 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object_collection.py @@ -344,7 +344,7 @@ def test_external_force_on_single_body_at_position(num_envs, num_cubes, device): object_collection.num_instances, len(object_ids), 3, device=sim.device ) # Every 2nd cube should have a force applied to it - external_wrench_b[:, 0::2, 2] = 500.0 + external_wrench_b[:, 0::2, 2] = 50.0 external_wrench_positions_b[:, 0::2, 1] = 1.0 # Desired force and torque diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index 555928ce4c23..d05b38808199 100644 --- a/source/isaaclab_physx/config/extension.toml +++ b/source/isaaclab_physx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.19" +version = "0.5.20" # Description title = "PhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index 99432eea87a3..d722acb0687a 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,18 @@ Changelog --------- +0.5.20 (2026-04-21) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Updated ``write_data_to_sim`` in :class:`~isaaclab_physx.assets.Articulation`, + :class:`~isaaclab_physx.assets.RigidObject`, and :class:`~isaaclab_physx.assets.RigidObjectCollection` + to use the dual-buffer :class:`~isaaclab.utils.wrench_composer.WrenchComposer`. Composed wrenches are + applied to PhysX with ``is_global=False`` after body-frame composition. + + 0.5.19 (2026-04-20) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index cf7d1f95d5ca..3b403ee8c6d4 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -237,30 +237,18 @@ def write_data_to_sim(self): # write external wrench if self._instantaneous_wrench_composer.active or self._permanent_wrench_composer.active: if self._instantaneous_wrench_composer.active: - # Compose instantaneous wrench with permanent wrench - self._instantaneous_wrench_composer.add_forces_and_torques_index( - forces=self._permanent_wrench_composer.composed_force, - torques=self._permanent_wrench_composer.composed_torque, - body_ids=self._ALL_BODY_INDICES, - env_ids=self._ALL_INDICES, - ) - # Apply both instantaneous and permanent wrench to the simulation - self.root_view.apply_forces_and_torques_at_position( - force_data=self._instantaneous_wrench_composer.composed_force.flatten().view(wp.float32), - torque_data=self._instantaneous_wrench_composer.composed_torque.flatten().view(wp.float32), - position_data=None, - indices=self._ALL_INDICES, - is_global=False, - ) + composer = self._instantaneous_wrench_composer + composer.add_raw_buffers_from(self._permanent_wrench_composer) else: - # Apply permanent wrench to the simulation - self.root_view.apply_forces_and_torques_at_position( - force_data=self._permanent_wrench_composer.composed_force.flatten().view(wp.float32), - torque_data=self._permanent_wrench_composer.composed_torque.flatten().view(wp.float32), - position_data=None, - indices=self._ALL_INDICES, - is_global=False, - ) + composer = self._permanent_wrench_composer + composer.compose_to_body_frame() + self.root_view.apply_forces_and_torques_at_position( + force_data=composer.out_force_b.flatten().view(wp.float32), + torque_data=composer.out_torque_b.flatten().view(wp.float32), + position_data=None, + indices=self._ALL_INDICES, + is_global=False, + ) self._instantaneous_wrench_composer.reset() # apply actuator models diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py index b549da96b787..8aa7dbd3f4f3 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py @@ -150,30 +150,18 @@ def write_data_to_sim(self) -> None: # write external wrench if self._instantaneous_wrench_composer.active or self._permanent_wrench_composer.active: if self._instantaneous_wrench_composer.active: - # Compose instantaneous wrench with permanent wrench - self._instantaneous_wrench_composer.add_forces_and_torques_index( - forces=self._permanent_wrench_composer.composed_force, - torques=self._permanent_wrench_composer.composed_torque, - body_ids=self._ALL_BODY_INDICES, - env_ids=self._ALL_INDICES, - ) - # Apply both instantaneous and permanent wrench to the simulation - self.root_view.apply_forces_and_torques_at_position( - force_data=self._instantaneous_wrench_composer.composed_force.flatten().view(wp.float32), - torque_data=self._instantaneous_wrench_composer.composed_torque.flatten().view(wp.float32), - position_data=None, - indices=self._ALL_INDICES, - is_global=False, - ) + composer = self._instantaneous_wrench_composer + composer.add_raw_buffers_from(self._permanent_wrench_composer) else: - # Apply permanent wrench to the simulation - self.root_view.apply_forces_and_torques_at_position( - force_data=self._permanent_wrench_composer.composed_force.flatten().view(wp.float32), - torque_data=self._permanent_wrench_composer.composed_torque.flatten().view(wp.float32), - position_data=None, - indices=self._ALL_INDICES, - is_global=False, - ) + composer = self._permanent_wrench_composer + composer.compose_to_body_frame() + self.root_view.apply_forces_and_torques_at_position( + force_data=composer.out_force_b.flatten().view(wp.float32), + torque_data=composer.out_torque_b.flatten().view(wp.float32), + position_data=None, + indices=self._ALL_INDICES, + is_global=False, + ) self._instantaneous_wrench_composer.reset() def update(self, dt: float) -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py index 877a44261293..3518aceac1d9 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py @@ -186,42 +186,20 @@ def write_data_to_sim(self) -> None: # write external wrench if self._instantaneous_wrench_composer.active or self._permanent_wrench_composer.active: if self._instantaneous_wrench_composer.active: - # Compose instantaneous wrench with permanent wrench - self._instantaneous_wrench_composer.add_forces_and_torques_index( - forces=self._permanent_wrench_composer.composed_force, - torques=self._permanent_wrench_composer.composed_torque, - body_ids=self._ALL_BODY_INDICES, - env_ids=self._ALL_ENV_INDICES, - ) - # Apply both instantaneous and permanent wrench to the simulation - self.root_view.apply_forces_and_torques_at_position( - force_data=self.reshape_data_to_view_2d( - self._instantaneous_wrench_composer.composed_force, device=self.device - ).view(wp.float32), - torque_data=self.reshape_data_to_view_2d( - self._instantaneous_wrench_composer.composed_torque, device=self.device - ).view(wp.float32), - position_data=None, - indices=self._env_body_ids_to_view_ids( - self._ALL_ENV_INDICES, self._ALL_BODY_INDICES, device=self.device - ), - is_global=False, - ) + composer = self._instantaneous_wrench_composer + composer.add_raw_buffers_from(self._permanent_wrench_composer) else: - # Apply permanent wrench to the simulation - self.root_view.apply_forces_and_torques_at_position( - force_data=self.reshape_data_to_view_2d( - self._permanent_wrench_composer.composed_force, device=self.device - ).view(wp.float32), - torque_data=self.reshape_data_to_view_2d( - self._permanent_wrench_composer.composed_torque, device=self.device - ).view(wp.float32), - position_data=None, - indices=self._env_body_ids_to_view_ids( - self._ALL_ENV_INDICES, self._ALL_BODY_INDICES, device=self.device - ), - is_global=False, - ) + composer = self._permanent_wrench_composer + composer.compose_to_body_frame() + self.root_view.apply_forces_and_torques_at_position( + force_data=self.reshape_data_to_view_2d(composer.out_force_b, device=self.device).view(wp.float32), + torque_data=self.reshape_data_to_view_2d(composer.out_torque_b, device=self.device).view(wp.float32), + position_data=None, + indices=self._env_body_ids_to_view_ids( + self._ALL_ENV_INDICES, self._ALL_BODY_INDICES, device=self.device + ), + is_global=False, + ) self._instantaneous_wrench_composer.reset() def update(self, dt: float) -> None: From 64a0f939d1478c342accf6c513982677ae6fa8bb Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Wed, 22 Apr 2026 16:57:39 +0000 Subject: [PATCH 23/37] tweak comments --- docs/source/features/visualization.rst | 2 +- source/isaaclab/isaaclab/visualizers/visualizer_cfg.py | 6 ++---- .../isaaclab_visualizers/kit/kit_visualizer.py | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index c093fe18f00f..2c84bd02fa95 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -366,7 +366,7 @@ server, allowing you to view and interact with the scene from any browser. Performance Note ---------------- -To reduce overhead when visualizing large-scale environments, consider: +When visualizing large-scale environments, consider: - Using Newton instead of Omniverse or Rerun - Reducing window sizes diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 3f62c3e5232e..74de203fa3c8 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -49,17 +49,15 @@ class VisualizerCfg: env_filter_mode: Literal["none", "env_ids", "random_n"] = "none" """Env filter mode: 'none', 'env_ids', or 'random_n'.""" - env_filter_random_n: int = 64 + env_filter_random_n: int = 16 """If env_filter_mode='random_n', number of envs to sample.""" env_filter_seed: int = 0 """Seed for deterministic env sampling.""" env_filter_ids: list[int] = [i for i in range(0, 64, 4)] - """If env_filter_mode='env_ids', only these env indices are shown. + """If env_filter_mode='env_ids', only these env indices are shown in visualizers. - This improves performance, particularly for large-scale training, by reducing scene updates sent to visualizers. - Note, OV visualizer only applies a cosmetic visibility toggle (no performance gain). """ def get_visualizer_type(self) -> str | None: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 3ad3ffd01326..f071167a3fce 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -75,7 +75,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._env_ids = self._compute_visualized_env_ids() if self._env_ids: logger.warning( - "[KitVisualizer] env_filter_ids filtering is cosmetic only (no perf gain) in OV; hiding other envs." + "[KitVisualizer] With env_filter_ids, Kit uses visibility only and hides unselected env prims." ) self._apply_env_visibility(usd_stage, metadata) num_visualized_envs = len(self._env_ids) if self._env_ids is not None else int(metadata.get("num_envs", 0)) From 4fd6ebd091e0e97b233935b43b17fad1c4200ec0 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Wed, 22 Apr 2026 17:08:11 +0000 Subject: [PATCH 24/37] lint --- source/isaaclab/isaaclab/app/app_launcher.py | 6 +++--- source/isaaclab/isaaclab/visualizers/visualizer_cfg.py | 7 +------ .../scene_data_providers/physx_scene_data_provider.py | 5 ++++- .../isaaclab_visualizers/kit/kit_visualizer.py | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 8fe7fadbad91..e3c384588a08 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -366,9 +366,9 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: - Multiple visualizers can be specified as a comma-delimited list: ``--viz rerun,newton,viser``. - * ``max_visible_envs`` (int | None): Optional global override for enabling partial visualizaiton by - capping the number of environments show in the visualizers, which can improve performance. - More partial visualization configuration fields are available in the VisualizerCfg class. + * ``max_visible_envs`` (int | None): Optional global override for partial visualization by capping + how many environments are shown in the visualizers. + More partial visualization configuration fields are available in the ``VisualizerCfg`` class. .. _`WebRTC`: https://docs.isaacsim.omniverse.nvidia.com/latest/installation/manual_livestream_clients.html#isaac-sim-short-webrtc-streaming-client diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 55348ecf6361..ec8ce46037f3 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -41,12 +41,7 @@ class VisualizerCfg: """Initial camera look-at point (x, y, z) in world coordinates.""" cam_source: Literal["cfg", "prim_path"] = "cfg" - """Camera source mode: 'cfg' uses eye/lookat, 'prim_path' follows a camera prim. - - For the Kit visualizer, ``cfg`` also means simulation-driven camera updates from - :class:`~isaaclab.envs.common.ViewerCfg` (e.g. via :class:`ViewportCameraController`) are not applied, - so set ``eye`` / ``lookat`` on the visualizer config for the dedicated viewport pose. - """ + """Camera source mode: 'cfg' uses eye/lookat, 'prim_path' follows a camera prim.""" cam_prim_path: str = "/World/envs/env_0/Camera" """Absolute USD path to a camera prim when cam_source='prim_path'.""" diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py index 2e285b922cc8..1a31ab2f70c6 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py @@ -489,7 +489,10 @@ def _read_poses_from_best_source(self) -> tuple[Any, Any, str, Any] | None: if rigid_count == 0: self._warn_once( "rigid-source-unused", - "[PhysxSceneDataProvider] RigidBodyView returned no transforms; filled from XformPrimView where needed.", + ( + "[PhysxSceneDataProvider] RigidBodyView returned no transforms; " + "filled from XformPrimView where needed." + ), level=logging.DEBUG, ) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 17f0bb08d818..684a97bb62f1 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -395,7 +395,7 @@ def _apply_env_visibility(self, usd_stage, metadata: dict, visible_env_ids: list self._apply_visual_point_instancer_visibility(usd_stage, num_envs, visible) def _apply_visual_point_instancer_visibility(self, usd_stage, num_envs: int, visible_env_ids: set[int]) -> None: - """Set ``PointInstancer.invisibleIds`` for `/Visuals` markers with one instance per env (e.g. velocity arrows).""" + """Set ``PointInstancer.invisibleIds`` for per-env `/Visuals` markers (e.g. velocity arrows).""" self._point_instancer_invisible_ids_backup.clear() hidden = [i for i in range(num_envs) if i not in visible_env_ids] vt_hidden = Vt.Int64Array([int(i) for i in hidden]) From cbadde306404db87af7d25d835934e82a7b6c2b8 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Wed, 22 Apr 2026 17:09:53 +0000 Subject: [PATCH 25/37] tweak comments --- docs/source/features/visualization.rst | 2 +- source/isaaclab/isaaclab/app/app_launcher.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 153242fcdf17..e06dd377a600 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -142,7 +142,7 @@ For the migration-focused summary and deprecation context, see Partial Visualization ~~~~~~~~~~~~~~~~~~~~~ -To improve performance, visualizers can be configured to visualize just a subset of environments. +Visualizers can be configured to visualize just a subset of environments. This is called partial visualization. There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments for partial visualization: diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index e3c384588a08..308e081c8226 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -523,7 +523,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "--max_visible_envs", type=int, default=argparse.SUPPRESS, - help=("When set, caps the nums of envs shown in the launched visualizers to improve performance."), + help=("When set, caps the nums of envs shown in the launched visualizers."), ) # special flag for backwards compatibility From a9e5f13d3b5f879aca393690c285aa43b62a3332 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Wed, 22 Apr 2026 17:11:05 +0000 Subject: [PATCH 26/37] docs --- docs/source/features/visualization.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index e06dd377a600..a1961ecccc84 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -158,9 +158,6 @@ There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments f Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. -Note, in the current release, the KitVisualizer does not fully support partial visualization. The non-selected environments -are made invisible which does not improve performance much. - .. _visualization-common-modes: .. list-table:: Common modes From 09fe93870d42f142d927c111e9f5529ab19f0046 Mon Sep 17 00:00:00 2001 From: rwiltz <165190220+rwiltz@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:52:50 -0400 Subject: [PATCH 27/37] Wires up teleop control states via Isaac Teleop message channel (#5268) # Description - Add message-channel-based start/stop/reset control from the XR headset, replacing the legacy carb message bus path with TeleopCore's native `teleop_control_pipeline`. - Introduce `ControlEvents` dataclass, `poll_control_events()` helper, and `MessageChannelTeleopStateManager` for consuming control commands over the OpenXR opaque data channel (`XR_NV_opaque_data_channel`). - Bridge pipeline-based control events to legacy `add_callback()` callbacks so existing scripts work without migration. - Fix `IsaacTeleopDevice.reset()` to propagate reset to retargeters via `ExecutionEvents`, and fix `record_demos.py` to reset the teleop device on success-triggered environment resets. - Fix shutdown hang caused by Kit's pre-shutdown callback racing with the simulation loop. Fixes # (issue) ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- docs/source/features/isaac_teleop.rst | 108 +++- .../teleoperation/teleop_se3_agent.py | 9 +- scripts/tools/record_demos.py | 44 +- source/isaaclab_teleop/config/extension.toml | 2 +- source/isaaclab_teleop/docs/CHANGELOG.rst | 45 ++ .../isaaclab_teleop/__init__.pyi | 9 +- .../isaaclab_teleop/command_handler.py | 72 +-- .../isaaclab_teleop/control_events.py | 78 +++ .../isaaclab_teleop/isaac_teleop_cfg.py | 19 + .../isaaclab_teleop/isaac_teleop_device.py | 63 +- .../isaaclab_teleop/session_lifecycle.py | 193 +++++- .../teleop_message_processor.py | 232 +++++++ .../test/test_cloudxr_lifecycle.py | 8 + .../test/test_control_events.py | 586 ++++++++++++++++++ 14 files changed, 1358 insertions(+), 110 deletions(-) create mode 100644 source/isaaclab_teleop/isaaclab_teleop/control_events.py create mode 100644 source/isaaclab_teleop/isaaclab_teleop/teleop_message_processor.py create mode 100644 source/isaaclab_teleop/test/test_control_events.py diff --git a/docs/source/features/isaac_teleop.rst b/docs/source/features/isaac_teleop.rst index ac910d6ed4ed..733150259586 100644 --- a/docs/source/features/isaac_teleop.rst +++ b/docs/source/features/isaac_teleop.rst @@ -115,8 +115,10 @@ and Isaac Lab. It composes three collaborators: Isaac Sim's XR bridge, creates the ``TeleopSession``, and steps it each frame to produce an action tensor. -* **CommandHandler** -- registers and dispatches START / STOP / RESET callbacks triggered by XR UI - buttons or the message bus. +* **CommandHandler** -- lightweight callback registry for START / STOP / RESET commands. Scripts + can register callbacks via :meth:`~isaaclab_teleop.IsaacTeleopDevice.add_callback`, but the + primary control path uses :func:`~isaaclab_teleop.poll_control_events` (see + :ref:`isaac-teleop-control-states`). .. dropdown:: Session lifecycle details @@ -127,6 +129,104 @@ and Isaac Lab. It composes three collaborators: the session is not yet ready or has been torn down. +.. _isaac-teleop-control-states: + +Teleop Control States (Start / Stop / Reset) +--------------------------------------------- + +Isaac Lab supports remote teleop control commands -- **start**, **stop**, and **reset** -- sent +from the XR headset to the simulation. These are used to begin and end demonstration recording, +pause the robot, or reset the environment without touching the simulation host. + +How it works +~~~~~~~~~~~~ + +By default, every :class:`~isaaclab_teleop.IsaacTeleopCfg` enables a control message channel +using the well-known UUID ``uuid5(NAMESPACE_DNS, "teleop_command")``. The channel is created as +a ``teleop_control_pipeline`` inside TeleopCore's :class:`TeleopSession`, which means: + +1. A :class:`~isaacteleop.retargeting_engine.deviceio_source_nodes.MessageChannelSource` opens an + OpenXR opaque data channel (``XR_NV_opaque_data_channel``) with the agreed-upon UUID. +2. The CloudXR JS client (or any other client) discovers the channel by UUID and sends UTF-8 + JSON commands:: + + {"type": "teleop_command", "message": {"command": "start teleop"}} + {"type": "teleop_command", "message": {"command": "stop teleop"}} + {"type": "teleop_command", "message": {"command": "reset teleop"}} + +3. A :class:`~isaaclab_teleop.teleop_message_processor.TeleopMessageProcessor` parses these + payloads and produces boolean pulse signals (``run_toggle``, ``kill``, ``reset``). +4. :class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager` consumes the + boolean signals, runs its state machine (edge detection, fail-safe), and produces + ``teleop_state`` (one-hot) and ``reset_event`` (bool pulse) outputs. +5. TeleopCore decodes these outputs into ``ExecutionEvents`` and injects them into every + retargeter's ``ComputeContext``, so stateful retargeters can react to state changes + (e.g. reinitializing cross-step state on reset). + +Polling control events in your script +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use :func:`~isaaclab_teleop.poll_control_events` to read the latest control state each frame: + +.. code-block:: python + + from isaaclab_teleop import poll_control_events + + with IsaacTeleopDevice(cfg) as device: + running = False + while sim_app.is_running(): + action = device.advance() + + ctrl = poll_control_events(device) + if ctrl.is_active is not None: + running = ctrl.is_active # True after "start", False after "stop" + if ctrl.should_reset: + env.reset() # "reset" command received this frame + + if action is not None and running: + env.step(action.repeat(num_envs, 1)) + else: + env.sim.render() + +:class:`~isaaclab_teleop.ControlEvents` has two fields: + +* ``is_active`` -- ``True`` after a "start" command, ``False`` after "stop", ``None`` when no + command has been received yet (callers should leave their own flag unchanged). +* ``should_reset`` -- ``True`` for exactly one frame after a "reset" command. + +Disabling the control channel +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you do not need headset-driven start/stop/reset (e.g. keyboard-only workflows), set +``control_channel_uuid=None`` in your config: + +.. code-block:: python + + IsaacTeleopCfg( + pipeline_builder=_build_my_pipeline, + control_channel_uuid=None, # no opaque data channel created + ) + +Using a custom channel UUID +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To use a different channel UUID (e.g. for a separate control protocol), pass any 16-byte +``bytes`` value: + +.. code-block:: python + + import uuid + + MY_UUID = uuid.uuid5(uuid.NAMESPACE_DNS, "my_custom_control").bytes + + IsaacTeleopCfg( + pipeline_builder=_build_my_pipeline, + control_channel_uuid=MY_UUID, + ) + +The CloudXR JS client must be updated to discover this UUID when sending commands. + + .. _isaac-teleop-retargeting: Retargeting Framework @@ -908,6 +1008,10 @@ See the :ref:`isaaclab_teleop-api` for full class and function documentation: * :class:`~isaaclab_teleop.IsaacTeleopCfg` * :class:`~isaaclab_teleop.IsaacTeleopDevice` * :func:`~isaaclab_teleop.create_isaac_teleop_device` +* :class:`~isaaclab_teleop.ControlEvents` +* :class:`~isaaclab_teleop.SupportsControlEvents` +* :func:`~isaaclab_teleop.poll_control_events` +* :data:`~isaaclab_teleop.TELEOP_CONTROL_CHANNEL_UUID` * :class:`~isaaclab_teleop.XrCfg` * :class:`~isaaclab_teleop.XrAnchorRotationMode` diff --git a/scripts/environments/teleoperation/teleop_se3_agent.py b/scripts/environments/teleoperation/teleop_se3_agent.py index 897eb159e86e..cdd5c104c44f 100644 --- a/scripts/environments/teleoperation/teleop_se3_agent.py +++ b/scripts/environments/teleoperation/teleop_se3_agent.py @@ -218,7 +218,7 @@ def stop_teleoperation() -> None: try: if use_isaac_teleop: - from isaaclab_teleop import create_isaac_teleop_device + from isaaclab_teleop import create_isaac_teleop_device, poll_control_events teleop_interface = create_isaac_teleop_device( env_cfg.isaac_teleop, @@ -297,6 +297,13 @@ def run_loop(): # get device command action = teleop_interface.advance() + if use_isaac_teleop: + ctrl = poll_control_events(teleop_interface) + if ctrl.is_active is not None: + teleoperation_active = ctrl.is_active + if ctrl.should_reset: + should_reset_recording_instance = True + # action is None when IsaacTeleop session hasn't started yet # (e.g. waiting for user to click "Start AR") if action is None: diff --git a/scripts/tools/record_demos.py b/scripts/tools/record_demos.py index bd318b7a2625..75df9e0ee92a 100644 --- a/scripts/tools/record_demos.py +++ b/scripts/tools/record_demos.py @@ -406,26 +406,34 @@ def process_success_condition(env: gym.Env, success_term: object | None, success def handle_reset( - env: gym.Env, success_step_count: int, instruction_display: InstructionDisplay, label_text: str + env: gym.Env, + success_step_count: int, + instruction_display: InstructionDisplay, + label_text: str, + teleop_interface: object | None = None, ) -> int: """Handle resetting the environment. - Resets the environment, recorder manager, and related state variables. - Updates the instruction display with current status. + Resets the environment, recorder manager, teleop device, and related + state variables. Updates the instruction display with current status. Args: - env: The environment instance to reset - success_step_count: Current count of consecutive successful steps - instruction_display: The display object to update - label_text: Text to display showing current recording status + env: The environment instance to reset. + success_step_count: Current count of consecutive successful steps. + instruction_display: The display object to update. + label_text: Text to display showing current recording status. + teleop_interface: Optional teleop device to reset (resets XR anchor + and retargeter cross-step state). Returns: - int: Reset success step count (0) + Reset success step count (0). """ print("Resetting environment...") env.sim.reset() env.recorder_manager.reset() env.reset() + if teleop_interface is not None and hasattr(teleop_interface, "reset"): + teleop_interface.reset() success_step_count = 0 instruction_display.show_demo(label_text) return success_step_count @@ -476,7 +484,9 @@ def stop_recording_instance(): running_recording_instance = False print("Recording paused") - # Set up teleoperation callbacks + # Set up teleoperation callbacks. For IsaacTeleop the primary control + # path is poll_control_events(); these callbacks are bridged automatically + # and also serve native (keyboard / spacemouse) devices. teleoperation_callbacks = { "R": reset_recording_instance, "START": start_recording_instance, @@ -485,7 +495,6 @@ def stop_recording_instance(): } teleop_interface = setup_teleop_device(teleoperation_callbacks, use_isaac_teleop) - teleop_interface.add_callback("R", reset_recording_instance) label_text = f"Recorded {current_recorded_demo_count} successful demonstrations." instruction_display = setup_ui(label_text, env) @@ -504,10 +513,21 @@ def inner_loop(): stack_name = "IsaacTeleop" if use_isaac_teleop else "native" print(f"{stack_name} recording started.") + if use_isaac_teleop: + from isaaclab_teleop import poll_control_events + with contextlib.suppress(KeyboardInterrupt), torch.inference_mode(): while simulation_app.is_running(): # Get teleop command (may be None while waiting for session start) action = teleop_interface.advance() + + if use_isaac_teleop: + ctrl = poll_control_events(teleop_interface) + if ctrl.is_active is not None: + running_recording_instance = ctrl.is_active + if ctrl.should_reset: + should_reset_recording_instance = True + if action is None: env.sim.render() continue @@ -558,7 +578,9 @@ def inner_loop(): # Handle reset if requested if should_reset_recording_instance: - success_step_count = handle_reset(env, success_step_count, instruction_display, label_text) + success_step_count = handle_reset( + env, success_step_count, instruction_display, label_text, teleop_interface + ) should_reset_recording_instance = False # Check if simulation is stopped diff --git a/source/isaaclab_teleop/config/extension.toml b/source/isaaclab_teleop/config/extension.toml index 881c57a52727..13c63e04ab99 100644 --- a/source/isaaclab_teleop/config/extension.toml +++ b/source/isaaclab_teleop/config/extension.toml @@ -1,6 +1,6 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.3.5" +version = "0.3.6" # Description title = "Isaac Lab Teleop" diff --git a/source/isaaclab_teleop/docs/CHANGELOG.rst b/source/isaaclab_teleop/docs/CHANGELOG.rst index d526500022c1..9ad0bf77ecd2 100644 --- a/source/isaaclab_teleop/docs/CHANGELOG.rst +++ b/source/isaaclab_teleop/docs/CHANGELOG.rst @@ -1,6 +1,51 @@ Changelog --------- +0.3.6 (2026-04-21) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :attr:`~isaaclab_teleop.IsaacTeleopCfg.control_channel_uuid` for + receiving teleop control commands (start/stop/reset) from the headset via + an OpenXR message channel. The channel is managed by TeleopCore's native + ``teleop_control_pipeline`` mechanism. + +* Added :class:`~isaaclab_teleop.teleop_message_processor.TeleopMessageProcessor` + retargeter that converts raw message-channel payloads into boolean control + signals for :class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager`. + +* Added :func:`~isaaclab_teleop.poll_control_events` helper, + :class:`~isaaclab_teleop.ControlEvents` dataclass, and + :class:`~isaaclab_teleop.SupportsControlEvents` protocol for polling + start/stop/reset signals from any teleop device in a single call. + +* Added :attr:`~isaaclab_teleop.IsaacTeleopDevice.last_control_events` + property exposing the most recent control events from the message channel. + Control events are automatically bridged to legacy + :meth:`~isaaclab_teleop.IsaacTeleopDevice.add_callback` callbacks. + +Changed +^^^^^^^ + +* :meth:`~isaaclab_teleop.IsaacTeleopDevice.reset` now injects a + ``reset`` :class:`ExecutionEvents` into TeleopCore's ``ComputeContext`` + on the next pipeline step, resetting retargeter cross-step state. + Previously only the XR anchor was reset. + +Fixed +^^^^^ + +* Fixed ``record_demos.py`` not resetting the teleop device when a + success condition triggers an environment reset. Retargeters now + reinitialize their state on success-triggered resets. + +* Fixed shutdown hang caused by Kit's pre-shutdown callback calling + ``stop()`` while the simulation loop was still running. The callback + now uses the same graceful teardown path as the XR-disabled handler. + + 0.3.5 (2026-04-06) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_teleop/isaaclab_teleop/__init__.pyi b/source/isaaclab_teleop/isaaclab_teleop/__init__.pyi index 655c7025cb0f..045f16f0c690 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/__init__.pyi +++ b/source/isaaclab_teleop/isaaclab_teleop/__init__.pyi @@ -6,15 +6,20 @@ __all__ = [ "CLOUDXR_AVP_ENV", "CLOUDXR_JS_ENV", + "ControlEvents", "IsaacTeleopCfg", "IsaacTeleopDevice", - "create_isaac_teleop_device", - "XrAnchorSynchronizer", + "SupportsControlEvents", + "TELEOP_CONTROL_CHANNEL_UUID", "XrAnchorRotationMode", + "XrAnchorSynchronizer", "XrCfg", + "create_isaac_teleop_device", + "poll_control_events", "remove_camera_configs", ] +from .control_events import TELEOP_CONTROL_CHANNEL_UUID, ControlEvents, SupportsControlEvents, poll_control_events from .isaac_teleop_cfg import CLOUDXR_AVP_ENV, CLOUDXR_JS_ENV, IsaacTeleopCfg from .isaac_teleop_device import IsaacTeleopDevice, create_isaac_teleop_device from .xr_anchor_utils import XrAnchorSynchronizer diff --git a/source/isaaclab_teleop/isaaclab_teleop/command_handler.py b/source/isaaclab_teleop/isaaclab_teleop/command_handler.py index eb5fb38aeb44..7e999e638f5e 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/command_handler.py +++ b/source/isaaclab_teleop/isaaclab_teleop/command_handler.py @@ -3,57 +3,30 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Teleop command handling for IsaacTeleop-based teleoperation.""" +"""Teleop command callback registry for IsaacTeleop-based teleoperation.""" from __future__ import annotations -import logging from collections.abc import Callable -from typing import Any - -import carb - -logger = logging.getLogger(__name__) class CommandHandler: - """Handles teleop command callbacks and XR message bus events. - - This class is responsible for: - - 1. Registering callbacks for teleop commands (START, STOP, RESET) - 2. Subscribing to the XR message bus for command events - 3. Dispatching callbacks when commands are received - - Teleop commands can be triggered via XR controller buttons or the - message bus. The handler normalizes command names (e.g. mapping - ``"R"`` to ``"RESET"``) and dispatches to registered callbacks. + """Lightweight callback registry for teleop commands. + + Scripts can register callbacks for ``START``, ``STOP``, and ``RESET`` + commands via :meth:`add_callback`. The callbacks are dispatched by + :meth:`fire` when the corresponding command is received. + + Note: + In the current architecture control signals arrive through + TeleopCore's ``teleop_control_pipeline`` and are consumed via + :func:`~isaaclab_teleop.poll_control_events`. This registry is + retained for backward compatibility with scripts that register + callbacks before the pipeline-based path was introduced. """ - TELEOP_COMMAND_EVENT_TYPE = "teleop_command" - - def __init__(self, xr_core: Any | None = None, on_reset: Callable[[], None] | None = None): - """Initialize the command handler. - - Args: - xr_core: The XRCore singleton, or ``None`` if XR is not available. - When provided, the handler subscribes to the message bus for - teleop command events. - on_reset: Optional hook called whenever a ``"reset"`` message-bus - event is received, *in addition to* the user's RESET callback. - This allows the device to perform internal reset actions (e.g. - resetting the XR anchor) without coupling the handler to the - anchor manager. - """ + def __init__(self) -> None: self._callbacks: dict[str, Callable] = {} - self._on_reset = on_reset - self._xr_core = xr_core - self._vc_subscription = None - - if self._xr_core is not None: - self._vc_subscription = self._xr_core.get_message_bus().create_subscription_to_pop_by_type( - carb.events.type_from_string(self.TELEOP_COMMAND_EVENT_TYPE), self._on_teleop_command - ) @property def callbacks(self) -> dict[str, Callable]: @@ -70,7 +43,6 @@ def add_callback(self, key: str, func: Callable) -> None: func: The function to call when the command is received. Should take no arguments. """ - # Map "R" to "RESET" for compatibility with existing scripts if key == "R": key = "RESET" self._callbacks[key] = func @@ -84,19 +56,5 @@ def fire(self, command: str) -> None: if command in self._callbacks: self._callbacks[command]() - def _on_teleop_command(self, event: carb.events.IEvent) -> None: - """Handle teleop command events from the message bus.""" - msg = event.payload.get("message", "") - - if "start" in msg: - self.fire("START") - elif "stop" in msg: - self.fire("STOP") - elif "reset" in msg: - self.fire("RESET") - if self._on_reset is not None: - self._on_reset() - def cleanup(self) -> None: - """Release event subscriptions.""" - self._vc_subscription = None + """Release resources (no-op; retained for API compatibility).""" diff --git a/source/isaaclab_teleop/isaaclab_teleop/control_events.py b/source/isaaclab_teleop/isaaclab_teleop/control_events.py new file mode 100644 index 000000000000..69ddd7c1ed21 --- /dev/null +++ b/source/isaaclab_teleop/isaaclab_teleop/control_events.py @@ -0,0 +1,78 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Teleop control events dataclass, polling helper, and well-known channel UUID.""" + +from __future__ import annotations + +import dataclasses +import uuid +from typing import Protocol, runtime_checkable + +TELEOP_CONTROL_CHANNEL_UUID: bytes = uuid.uuid5(uuid.NAMESPACE_DNS, "teleop_command").bytes +"""Well-known 16-byte UUID for the teleop control message channel. + +Derived deterministically as ``uuid5(NAMESPACE_DNS, "teleop_command")`` +so that both the Isaac Lab server and the Quest client can independently +compute the same channel identifier from the string ``"teleop_command"``. + +Pass this value as :attr:`~isaaclab_teleop.IsaacTeleopCfg.control_channel_uuid` +when configuring a teleop session with message-channel-based control. +""" + + +@dataclasses.dataclass(frozen=True) +class ControlEvents: + """Result of :func:`poll_control_events`. + + Attributes: + is_active: ``True`` when the teleop state machine is in RUNNING, + ``False`` when PAUSED or STOPPED, or ``None`` when no control + channel is configured (callers should leave their own active + flag unchanged). + should_reset: ``True`` when a reset was triggered this frame. + """ + + is_active: bool | None = None + should_reset: bool = False + + +_NO_OP_EVENTS = ControlEvents() +"""Shared immutable sentinel returned when no control channel is active.""" + + +@runtime_checkable +class SupportsControlEvents(Protocol): + """Duck type for teleop devices that expose control events.""" + + @property + def last_control_events(self) -> ControlEvents: ... + + +def poll_control_events(teleop_interface: SupportsControlEvents | object) -> ControlEvents: + """Poll control events from any teleop interface. + + Safe to call with any device type (keyboard, spacemouse, etc.). + Devices that do not expose the message-channel protocol return + a no-op :class:`ControlEvents`. + + Args: + teleop_interface: The teleop device to poll. Devices implementing + :class:`SupportsControlEvents` provide full type safety; other + devices are handled gracefully via duck typing. + + Returns: + A :class:`ControlEvents` with the latest start/stop and reset + signals. + """ + events = getattr(teleop_interface, "last_control_events", None) + if events is None: + return _NO_OP_EVENTS + if isinstance(events, ControlEvents): + return events + return ControlEvents( + is_active=getattr(events, "is_active", None), + should_reset=getattr(events, "should_reset", False), + ) diff --git a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py index 6539fa67f346..f94a63d57589 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py +++ b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_cfg.py @@ -14,6 +14,7 @@ from isaaclab.utils import configclass +from .control_events import TELEOP_CONTROL_CHANNEL_UUID from .xr_cfg import XrCfg _CLOUDXR_ENV_DIR = Path(__file__).resolve().parent @@ -117,6 +118,24 @@ def build_pipeline(): If ``None``, the tuning UI will not be opened. """ + control_channel_uuid: bytes | None = TELEOP_CONTROL_CHANNEL_UUID + """16-byte UUID for the teleop control message channel. + + Defaults to :data:`~isaaclab_teleop.TELEOP_CONTROL_CHANNEL_UUID` + (``uuid5(NAMESPACE_DNS, "teleop_command")``), which is the well-known + channel both the Isaac Lab server and CloudXR JS client use to + exchange start/stop/reset commands. + + When set, a ``teleop_control_pipeline`` is created automatically + using :class:`~isaaclab_teleop.teleop_message_processor.TeleopMessageProcessor` + and :class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager`. + The remote client sends UTF-8 control commands over the OpenXR opaque + data channel identified by this UUID, and the results are exposed via + :func:`~isaaclab_teleop.poll_control_events`. + + Set to ``None`` to disable the control channel entirely. + """ + target_frame_prim_path: str | None = None """Optional USD prim path whose world frame becomes the target coordinate frame for all output poses. diff --git a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py index 2e7c2c7a406b..3f8c565a7e21 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py +++ b/source/isaaclab_teleop/isaaclab_teleop/isaac_teleop_device.py @@ -15,6 +15,7 @@ import torch from .command_handler import CommandHandler +from .control_events import ControlEvents from .isaac_teleop_cfg import IsaacTeleopCfg from .session_lifecycle import TeleopSessionLifecycle from .xr_anchor_manager import XrAnchorManager @@ -35,8 +36,8 @@ class IsaacTeleopDevice: and coordinate-frame transform computation. * :class:`TeleopSessionLifecycle` -- pipeline building, OpenXR handle acquisition, session creation/destruction, and action-tensor extraction. - * :class:`CommandHandler` -- callback registration and XR message-bus - command dispatch. + * :class:`CommandHandler` -- callback registration for START / STOP / RESET + commands, bridged from the pipeline-based control events. Together they manage: @@ -67,7 +68,8 @@ class IsaacTeleopDevice: Teleop commands: The device supports callbacks for START, STOP, and RESET commands - that can be triggered via XR controller buttons or the message bus. + that can be triggered via the message-channel control pipeline or + registered directly via :meth:`add_callback`. Example: .. code-block:: python @@ -118,20 +120,16 @@ def __init__( """ self._cfg = cfg - # Compose the three collaborators self._anchor_manager = XrAnchorManager(cfg.xr_cfg) + self._command_handler = CommandHandler() self._session_lifecycle = TeleopSessionLifecycle( cfg, cloudxr_env_file=cloudxr_env_file, auto_launch_cloudxr=auto_launch_cloudxr, ) - self._command_handler = CommandHandler( - xr_core=self._anchor_manager.xr_core, - on_reset=self._anchor_manager.reset, - ) - # Controller button polling state (edge detection for right 'A') self._prev_right_a_pressed = False + self._prev_control_is_active: bool | None = None def __del__(self): """Clean up resources when the object is destroyed.""" @@ -188,9 +186,23 @@ def __exit__(self, exc_type, exc_val, exc_tb): def reset(self) -> None: """Reset the device state. - Resets the XR anchor synchronizer if present. + Resets the XR anchor synchronizer and schedules a + ``reset`` :class:`~isaacteleop.retargeting_engine.interface.execution_events.ExecutionEvents` + for the next pipeline step so that all retargeters reinitialize + their cross-step state. """ self._anchor_manager.reset() + self._session_lifecycle.request_reset() + + @property + def last_control_events(self) -> ControlEvents: + """Control events from the most recent :meth:`advance`. + + Returns a :class:`ControlEvents` derived from the teleop control + pipeline. When no control channel is configured, returns a + default (no-op) :class:`ControlEvents`. + """ + return self._session_lifecycle.last_control_events def add_callback(self, key: str, func: Callable) -> None: """Add a callback function for teleop commands. @@ -252,8 +264,39 @@ def advance(self, target_T_world: np.ndarray | torch.Tensor | SupportsDLPack | N # Poll controller buttons (e.g. toggle anchor rotation on right 'A' press) self._poll_buttons() + self._dispatch_control_callbacks() + return action + # ------------------------------------------------------------------ + # Control event -> callback bridge + # ------------------------------------------------------------------ + + def _dispatch_control_callbacks(self) -> None: + """Fire legacy callbacks when control events indicate a state change. + + This bridges the pipeline-based :class:`ControlEvents` with the + callback-based :class:`CommandHandler` so that scripts which registered + callbacks via :meth:`add_callback` still receive dispatches. + + Only fires START/STOP when ``is_active`` transitions between ``True`` + and ``False``; initial transitions from ``None`` are ignored to avoid + spurious callbacks during ``DefaultTeleopStateManager``'s + STOPPED -> PAUSED progression. + """ + from .control_events import _NO_OP_EVENTS + + events = self._session_lifecycle.last_control_events + if events is _NO_OP_EVENTS: + return + if events.should_reset: + self._command_handler.fire("RESET") + self._anchor_manager.reset() + if events.is_active is not None: + if self._prev_control_is_active is not None and events.is_active != self._prev_control_is_active: + self._command_handler.fire("START" if events.is_active else "STOP") + self._prev_control_is_active = events.is_active + # ------------------------------------------------------------------ # Target frame transform (config-driven rebase) # ------------------------------------------------------------------ diff --git a/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py b/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py index d395fab31a30..fa5f36f658e0 100644 --- a/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py +++ b/source/isaaclab_teleop/isaaclab_teleop/session_lifecycle.py @@ -18,10 +18,13 @@ if TYPE_CHECKING: from isaacteleop.cloudxr import CloudXRLauncher from isaacteleop.oxr import OpenXRSessionHandles + from isaacteleop.retargeting_engine.interface.execution_events import ExecutionEvents from isaacteleop.retargeting_engine_ui import MultiRetargeterTuningUIImGui from isaacteleop.teleop_session_manager import TeleopSession +from .control_events import _NO_OP_EVENTS, ControlEvents from .isaac_teleop_cfg import IsaacTeleopCfg +from .teleop_message_processor import TeleopMessageProcessor class SupportsDLPack(Protocol): @@ -71,6 +74,19 @@ def _to_numpy_4x4(mat: np.ndarray | torch.Tensor | SupportsDLPack) -> np.ndarray return np.asarray(mat, dtype=np.float32) +def _execution_events_to_control(ee: ExecutionEvents) -> ControlEvents: + """Map TeleopCore :class:`ExecutionEvents` to the script-facing :class:`ControlEvents`.""" + from isaacteleop.retargeting_engine.interface.execution_events import ExecutionState + + if ee.execution_state == ExecutionState.RUNNING: + is_active: bool | None = True + elif ee.execution_state in (ExecutionState.PAUSED, ExecutionState.STOPPED): + is_active = False + else: + is_active = None + return ControlEvents(is_active=is_active, should_reset=ee.reset) + + class TeleopSessionLifecycle: """Manages the IsaacTeleop session lifecycle. @@ -78,11 +94,13 @@ class TeleopSessionLifecycle: 1. Building the retargeting pipeline from configuration 2. Adding a parallel ``ControllersSource`` for button-state access - 3. Acquiring OpenXR handles from Kit's XR bridge extension - 4. Creating, entering, and exiting the ``TeleopSession`` - 5. Building external inputs for pipeline leaf nodes (e.g. world-to-anchor transform) - 6. Stepping the session and extracting the flattened action tensor - 7. Managing the optional retargeting tuning UI + 3. Building the optional ``teleop_control_pipeline`` for headset-driven + start/stop/reset via a message channel + 4. Acquiring OpenXR handles from Kit's XR bridge extension + 5. Creating, entering, and exiting the ``TeleopSession`` + 6. Building external inputs for pipeline leaf nodes (e.g. world-to-anchor transform) + 7. Stepping the session and extracting the flattened action tensor + 8. Managing the optional retargeting tuning UI """ WORLD_T_ANCHOR_INPUT_NAME = "world_T_anchor" @@ -118,8 +136,12 @@ def __init__( # Session state (populated during start) self._session: TeleopSession | None = None self._pipeline = None + self._teleop_control_pipeline = None + self._message_processor: TeleopMessageProcessor | None = None self._last_right_controller = None self._session_start_deferred_logged = False + # Fallback for host-initiated resets when no control pipeline is configured + self._pending_reset = False # CloudXR runtime launcher (created in start if configured, stopped in stop) self._cloudxr_launcher: CloudXRLauncher | None = None @@ -192,6 +214,48 @@ def last_right_controller(self): """ return self._last_right_controller + @property + def has_control_channel(self) -> bool: + """Whether a message-channel-based control pipeline is configured.""" + return self._message_processor is not None + + @property + def last_control_events(self) -> ControlEvents: + """Control events from the most recent :meth:`step`. + + When a ``teleop_control_pipeline`` is configured, derives + :class:`ControlEvents` from + ``session.last_context.execution_events``. Otherwise returns a + default (no-op) :class:`ControlEvents`. + """ + if self._message_processor is None: + return _NO_OP_EVENTS + if self._session is None: + return _NO_OP_EVENTS + ctx = self._session.last_context + if ctx is None: + return _NO_OP_EVENTS + return _execution_events_to_control(ctx.execution_events) + + def request_reset(self) -> None: + """Schedule a reset for the next pipeline step. + + When a control pipeline is configured, the reset flows through + :meth:`TeleopMessageProcessor.inject_reset` so + :class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager` + processes it normally. Otherwise falls back to an + ``execution_events`` override on the next :meth:`step` call. + + If the control channel already processed a reset this frame, + this method is a no-op to avoid a redundant second reset pulse. + """ + if self.last_control_events.should_reset: + return + if self._message_processor is not None: + self._message_processor.inject_reset() + else: + self._pending_reset = True + # ------------------------------------------------------------------ # Lifecycle: start / stop # ------------------------------------------------------------------ @@ -203,9 +267,10 @@ def start(self) -> None: the CloudXR runtime and WSS proxy are launched first. Builds the retargeting pipeline, wraps it with a parallel - ``ControllersSource`` for button-state access, attempts to acquire - OpenXR handles, and opens the retargeting tuning UI if retargeters - are configured. + ``ControllersSource`` for button-state access, builds the optional + ``teleop_control_pipeline`` for message-channel control, attempts + to acquire OpenXR handles, and opens the retargeting tuning UI if + retargeters are configured. If the OpenXR handles are not yet available (e.g. user hasn't clicked "Start AR"), session creation is deferred and will be retried on each @@ -222,12 +287,19 @@ def start(self) -> None: self._last_right_controller = None button_controllers = ControllersSource("_button_controllers") - self._pipeline = OutputCombiner( - { - "action": user_pipeline.output("action"), - self._CONTROLLER_RIGHT_KEY: button_controllers.output(ControllersSource.RIGHT), - } - ) + pipeline_outputs: dict[str, Any] = { + "action": user_pipeline.output("action"), + self._CONTROLLER_RIGHT_KEY: button_controllers.output(ControllersSource.RIGHT), + } + self._pipeline = OutputCombiner(pipeline_outputs) + + # Build optional teleop_control_pipeline for message-channel control + self._teleop_control_pipeline = None + self._message_processor = None + if self._cfg.control_channel_uuid is not None: + self._teleop_control_pipeline, self._message_processor = self._build_control_pipeline( + self._cfg.control_channel_uuid + ) # Try to start the session now; it may be deferred self._try_start_session() @@ -269,7 +341,12 @@ def stop(self, exc_type=None, exc_val=None, exc_tb=None) -> None: # expected and safe to suppress. logger.debug(f"Suppressed error during IsaacTeleop session cleanup: {e}") self._session = None - self._pipeline = None + + # Always clear pipeline state (session may never have been created if + # OpenXR handles were never available). + self._pipeline = None + self._teleop_control_pipeline = None + self._message_processor = None if self._cloudxr_launcher is not None: try: @@ -282,18 +359,68 @@ def stop(self, exc_type=None, exc_val=None, exc_tb=None) -> None: logger.info("IsaacTeleop session ended") + # ------------------------------------------------------------------ + # Control pipeline construction + # ------------------------------------------------------------------ + + @staticmethod + def _build_control_pipeline(channel_uuid: bytes) -> tuple[Any, TeleopMessageProcessor]: + """Build a ``teleop_control_pipeline`` from a message channel UUID. + + Wires ``MessageChannelSource`` -> :class:`TeleopMessageProcessor` + -> :class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager`. + + Args: + channel_uuid: 16-byte UUID for the OpenXR opaque data channel. + + Returns: + A ``(teleop_control_pipeline, message_processor)`` tuple. + """ + from isaacteleop.retargeting_engine.deviceio_source_nodes import message_channel_config + from isaacteleop.teleop_session_manager import DefaultTeleopStateManager + + source, _sink = message_channel_config( + name="_teleop_control", + channel_uuid=channel_uuid, + ) + + processor = TeleopMessageProcessor(name="_teleop_msg_processor") + processor_graph = processor.connect({processor.INPUT_MESSAGES: source.output("messages_tracked")}) + + state_manager = DefaultTeleopStateManager(name="_teleop_state") + teleop_control_pipeline = state_manager.connect( + { + state_manager.INPUT_KILL: processor_graph.output("kill"), + state_manager.INPUT_RUN_TOGGLE: processor_graph.output("run_toggle"), + state_manager.INPUT_RESET: processor_graph.output("reset"), + } + ) + + return teleop_control_pipeline, processor + + # ------------------------------------------------------------------ + # Extension / XR lifecycle callbacks + # ------------------------------------------------------------------ + def _on_request_required_extensions(self) -> list[str]: """Callback for required extensions subscription. + Inspects both the main pipeline and the ``teleop_control_pipeline`` + (if configured) so that extensions required by the control channel + (e.g. ``XR_NV_opaque_data_channel``) are included. + Returns: A list of required extensions. """ from isaacteleop.teleop_session_manager.helpers import get_required_oxr_extensions_from_pipeline - required_extensions = ( - get_required_oxr_extensions_from_pipeline(self._pipeline) if self._pipeline is not None else [] - ) + required_extensions: list[str] = [] + if self._pipeline is not None: + required_extensions.extend(get_required_oxr_extensions_from_pipeline(self._pipeline)) + if self._teleop_control_pipeline is not None: + required_extensions.extend(get_required_oxr_extensions_from_pipeline(self._teleop_control_pipeline)) + required_extensions = sorted(set(required_extensions)) logger.info(f"Required extensions: {required_extensions}") return required_extensions @@ -307,10 +434,16 @@ def _on_xr_enabled_changed(self, item, event_type): self._teardown_dead_session() def _on_pre_shutdown(self, _event): - """Called when Kit is closing; run full cleanup since the app is exiting.""" + """Called when Kit is closing; tear down the session but leave the + pipeline intact so the main loop can exit via its own control flow + (``simulation_app.is_running()`` will go ``False``). + + Full resource cleanup happens later when the context manager's + ``__exit__`` calls :meth:`stop`. + """ logger.info("Shutting down IsaacTeleop session due to Kit close") self._pre_shutdown_subscription = None - self.stop() + self._teardown_dead_session() # ------------------------------------------------------------------ # Deferred session creation @@ -341,11 +474,6 @@ def _try_start_session(self) -> bool: if self._session is not None: return True - # In headless mode the AR profile setting is deliberately omitted - # from the .kit file so that all extensions (including the teleop - # bridge and its BridgeComponent) can load and register before Kit - # creates the OpenXR instance. We enable it here, after extensions - # are loaded; Kit will process the change on the next event-loop tick. self._ensure_xr_ar_profile_enabled() from isaacteleop.oxr import OpenXRSessionHandles @@ -371,6 +499,7 @@ def _try_start_session(self) -> bool: app_name=self._cfg.app_name, trackers=[], pipeline=self._pipeline, + teleop_control_pipeline=self._teleop_control_pipeline, plugins=self._cfg.plugins, oxr_handles=oxr_handles, ) @@ -436,6 +565,15 @@ def step( # pipeline contains ValueInput leaf nodes. external_inputs = self._build_external_inputs(anchor_world_matrix_fn, target_T_world) + # When no control pipeline is configured, host-initiated resets use + # the execution_events override as a fallback path. + execution_events = None + if self._pending_reset: + from isaacteleop.retargeting_engine.interface.execution_events import ExecutionEvents, ExecutionState + + execution_events = ExecutionEvents(reset=True, execution_state=ExecutionState.RUNNING) + self._pending_reset = False + # Execute one step of the teleop session. # If the underlying OpenXR session was destroyed externally (e.g. # user clicked "Stop AR"), the step call will fail. We catch the @@ -443,7 +581,10 @@ def step( # can continue rendering (or wait for the session to restart). assert self._session is not None # guaranteed by _try_start_session above try: - result = self._session.step(external_inputs=external_inputs) + result = self._session.step( + external_inputs=external_inputs, + execution_events=execution_events, + ) except Exception as e: logger.warning(f"IsaacTeleop session step failed (XR session likely torn down): {e}") self._teardown_dead_session() diff --git a/source/isaaclab_teleop/isaaclab_teleop/teleop_message_processor.py b/source/isaaclab_teleop/isaaclab_teleop/teleop_message_processor.py new file mode 100644 index 000000000000..1844925c4d0c --- /dev/null +++ b/source/isaaclab_teleop/isaaclab_teleop/teleop_message_processor.py @@ -0,0 +1,232 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Message-channel payload parser for TeleopCore's teleop_control_pipeline. + +Provides :class:`TeleopMessageProcessor`, a lightweight +:class:`~isaacteleop.retargeting_engine.interface.BaseRetargeter` that +converts message-channel payloads into boolean pulse signals suitable for +:class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager`. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING + +from isaacteleop.retargeting_engine.interface import BaseRetargeter, RetargeterIOType + +if TYPE_CHECKING: + from isaacteleop.retargeting_engine.interface.retargeter_core_types import ComputeContext, RetargeterIO + +_COMMAND_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"\breset\b", re.IGNORECASE), "reset"), + (re.compile(r"\bstop\b", re.IGNORECASE), "stop"), + (re.compile(r"\bstart\b", re.IGNORECASE), "start"), +] +"""Ordered patterns for classifying a command string. + +``reset`` is checked first so that a hypothetical payload containing +both "reset" and "start" is treated as a reset (the more destructive +operation wins). ``stop`` precedes ``start`` for the same reason. +""" + +# Shadow states mirroring DefaultTeleopStateManager's ExecutionState. +_STOPPED = "stopped" +_PAUSED = "paused" +_RUNNING = "running" + +# DefaultTeleopStateManager cycles states on run_toggle rising edges: +# STOPPED -> PAUSED -> RUNNING -> PAUSED -> RUNNING -> ... +# To map imperative "start" (= go to RUNNING) and "stop" (= go to PAUSED) +# we emit the right number of toggle edges based on predicted state. +_START_TOGGLE_SEQUENCES: dict[str, list[bool]] = { + _STOPPED: [True, False, True], # 2 edges: STOPPED -> PAUSED -> RUNNING + _PAUSED: [True], # 1 edge: PAUSED -> RUNNING + _RUNNING: [], # already running +} +_STOP_TOGGLE_SEQUENCES: dict[str, list[bool]] = { + _RUNNING: [True], # 1 edge: RUNNING -> PAUSED + _PAUSED: [], # already paused + _STOPPED: [], # already stopped +} +# Shadow state advances on each rising edge (True after False). +_TOGGLE_TRANSITIONS: dict[str, str] = { + _STOPPED: _PAUSED, + _PAUSED: _RUNNING, + _RUNNING: _PAUSED, +} + + +class TeleopMessageProcessor(BaseRetargeter): + """Parse message-channel payloads into boolean control signals. + + Consumes the ``messages_tracked`` output of a + :class:`~isaacteleop.retargeting_engine.deviceio_source_nodes.MessageChannelSource` + and produces three boolean pulse outputs that drive + :class:`~isaacteleop.teleop_session_manager.DefaultTeleopStateManager`: + + * ``run_toggle`` -- pulsed ``True`` on rising edges; the number of + edges depends on the target state (e.g. ``"start"`` from STOPPED + emits two edges over three frames: STOPPED -> PAUSED -> RUNNING). + * ``kill`` -- always ``False`` (reserved for fail-safe; ``"stop"`` + uses ``run_toggle`` to reach PAUSED instead of STOPPED). + * ``reset`` -- pulsed ``True`` for one frame on ``"reset"``. + + The processor maintains a *shadow state* that mirrors + ``DefaultTeleopStateManager``'s internal state so it can emit the + correct toggle sequence for imperative commands. + + Payload formats supported: + + 1. **JSON (Quest client format)**:: + + {"type": "teleop_command", "message": {"command": "start teleop"}} + + 2. **Plain text (fallback)**: raw UTF-8 string matched by word boundary + (``"start"``, ``"stop"``, ``"reset"``). + + Host-initiated resets (e.g. environment success) are injected via + :meth:`inject_reset`, which sets the ``reset`` output ``True`` on the + next compute call without requiring a message-channel payload. + """ + + INPUT_MESSAGES = "messages_tracked" + + def __init__(self, name: str) -> None: + self._inject_reset_pending = False + self._shadow_state = _STOPPED + self._run_toggle_queue: list[bool] = [] + self._prev_toggle_output = False + super().__init__(name=name) + + def inject_reset(self) -> None: + """Schedule a reset pulse on the next pipeline step. + + The ``reset`` output will be ``True`` for exactly one frame, then + automatically cleared. + """ + self._inject_reset_pending = True + + def _make_toggle_sequence(self, base_sequence: list[bool]) -> list[bool]: + """Prepend a ``False`` frame if needed to guarantee a clean rising edge. + + ``DefaultTeleopStateManager`` uses edge detection + (``pressed and not prev_pressed``), so emitting ``True`` when the + previous output was already ``True`` would not trigger a state + transition. This method prepends ``False`` when necessary. + """ + if not base_sequence: + return [] + seq = list(base_sequence) + if self._prev_toggle_output: + seq.insert(0, False) + return seq + + def input_spec(self) -> RetargeterIOType: + from isaacteleop.retargeting_engine.deviceio_source_nodes.deviceio_tensor_types import ( + MessageChannelMessagesTrackedGroup, + ) + + return {self.INPUT_MESSAGES: MessageChannelMessagesTrackedGroup()} + + def output_spec(self) -> RetargeterIOType: + from isaacteleop.teleop_session_manager.teleop_state_manager_types import bool_signal + + return { + "run_toggle": bool_signal("run_toggle"), + "kill": bool_signal("kill"), + "reset": bool_signal("reset"), + } + + def _compute_fn( + self, + inputs: RetargeterIO, + outputs: RetargeterIO, + context: ComputeContext, + ) -> None: + del context + + reset = self._inject_reset_pending + self._inject_reset_pending = False + + # Parse incoming messages and enqueue toggle sequences. + messages_tracked = inputs[self.INPUT_MESSAGES][0] + data = getattr(messages_tracked, "data", None) + if data: + for message in data: + payload = getattr(message, "payload", None) + if payload is None: + continue + try: + text = bytes(payload).decode("utf-8") + except (UnicodeDecodeError, TypeError): + continue + + command = _extract_command(text) + if command is None: + continue + + kind = _classify_command(command) + if kind == "start" and not self._run_toggle_queue: + self._run_toggle_queue = self._make_toggle_sequence(_START_TOGGLE_SEQUENCES[self._shadow_state]) + elif kind == "stop" and not self._run_toggle_queue: + self._run_toggle_queue = self._make_toggle_sequence(_STOP_TOGGLE_SEQUENCES[self._shadow_state]) + elif kind == "reset": + reset = True + + # Drain the toggle queue (one value per frame). + if self._run_toggle_queue: + run_toggle = self._run_toggle_queue.pop(0) + else: + run_toggle = False + + # Advance shadow state on rising edges (matches DefaultTeleopStateManager's + # edge detection: ``pressed and not prev_pressed``). + if run_toggle and not self._prev_toggle_output: + self._shadow_state = _TOGGLE_TRANSITIONS[self._shadow_state] + self._prev_toggle_output = run_toggle + + outputs["run_toggle"][0] = run_toggle + outputs["kill"][0] = False + outputs["reset"][0] = reset + + +def _classify_command(text: str) -> str | None: + """Return ``"start"``, ``"stop"``, ``"reset"``, or ``None``. + + Uses word-boundary matching so that e.g. ``"stop_and_restart"`` + matches ``"stop"`` (not ``"start"``). + """ + for pattern, label in _COMMAND_PATTERNS: + if pattern.search(text): + return label + return None + + +def _extract_command(text: str) -> str | None: + """Extract the command string from a JSON or plain-text payload. + + Tries JSON parsing first (Quest client format) and falls back to the + raw text for plain-string payloads. Non-string JSON scalars (numbers, + arrays, booleans) are discarded. + """ + try: + obj = json.loads(text) + except (json.JSONDecodeError, TypeError): + return text + + if not isinstance(obj, dict): + return None + if obj.get("type") != "teleop_command": + return None + + msg = obj.get("message") + if isinstance(msg, dict): + return msg.get("command", "") + if isinstance(msg, str): + return msg + return None diff --git a/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py b/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py index e9565a7d3e41..43131f70cfc3 100644 --- a/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py +++ b/source/isaaclab_teleop/test/test_cloudxr_lifecycle.py @@ -38,8 +38,15 @@ "isaacteleop.oxr", "isaacteleop.retargeting_engine", "isaacteleop.retargeting_engine.interface", + "isaacteleop.retargeting_engine.interface.execution_events", + "isaacteleop.retargeting_engine.interface.retargeter_core_types", + "isaacteleop.retargeting_engine.interface.tensor_group_type", + "isaacteleop.retargeting_engine.deviceio_source_nodes", + "isaacteleop.retargeting_engine.deviceio_source_nodes.deviceio_tensor_types", "isaacteleop.retargeting_engine_ui", "isaacteleop.teleop_session_manager", + "isaacteleop.teleop_session_manager.teleop_state_manager_retargeter", + "isaacteleop.teleop_session_manager.teleop_state_manager_types", "isaacsim", "isaacsim.kit", "isaacsim.kit.xr", @@ -85,6 +92,7 @@ def _make_cfg() -> IsaacTeleopCfg: """Build a minimal IsaacTeleopCfg with a dummy pipeline_builder.""" return IsaacTeleopCfg( pipeline_builder=lambda: MagicMock(), + control_channel_uuid=None, ) diff --git a/source/isaaclab_teleop/test/test_control_events.py b/source/isaaclab_teleop/test/test_control_events.py new file mode 100644 index 000000000000..8bc05d3f957c --- /dev/null +++ b/source/isaaclab_teleop/test/test_control_events.py @@ -0,0 +1,586 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# pyright: reportPrivateUsage=none + +"""Tests for TeleopMessageProcessor, _classify_command, _extract_command, +and poll_control_events. + +These tests exercise pure logic (no Omniverse/Isaac Sim stack required). +The message processor is tested by calling its ``_compute_fn`` method +directly with fake pipeline I/O, mirroring how TeleopCore's +``teleop_control_pipeline`` mechanism invokes it. +""" + +from __future__ import annotations + +import dataclasses +import json +import sys +from types import ModuleType +from unittest.mock import MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Stub out isaacteleop modules before any isaaclab_teleop imports so the +# tests can run in a plain Python environment without Omniverse. +# --------------------------------------------------------------------------- + +_MODULES_TO_STUB = [ + "isaacteleop", + "isaacteleop.deviceio", + "isaacteleop.deviceio_trackers", + "isaacteleop.retargeting_engine", + "isaacteleop.retargeting_engine.deviceio_source_nodes", + "isaacteleop.retargeting_engine.deviceio_source_nodes.deviceio_tensor_types", + "isaacteleop.retargeting_engine.interface", + "isaacteleop.retargeting_engine.interface.retargeter_core_types", + "isaacteleop.retargeting_engine.interface.tensor_group_type", + "isaacteleop.retargeting_engine_ui", + "isaacteleop.schema", + "isaacteleop.teleop_session_manager", + "isaacteleop.teleop_session_manager.teleop_state_manager_retargeter", + "isaacteleop.teleop_session_manager.teleop_state_manager_types", +] + +_stubs: dict[str, ModuleType | MagicMock] = {} + + +def _install_stubs(): + for name in _MODULES_TO_STUB: + if name not in sys.modules: + _stubs[name] = MagicMock() + sys.modules[name] = _stubs[name] + + from enum import Enum + + class ExecutionState(str, Enum): + UNKNOWN = "unknown" + STOPPED = "stopped" + PAUSED = "paused" + RUNNING = "running" + + @dataclasses.dataclass + class ExecutionEvents: + reset: bool = False + execution_state: ExecutionState = ExecutionState.UNKNOWN + + ee_mod = sys.modules["isaacteleop.retargeting_engine.interface.execution_events"] = ModuleType( + "isaacteleop.retargeting_engine.interface.execution_events" + ) + ee_mod.ExecutionState = ExecutionState # type: ignore[attr-defined] + ee_mod.ExecutionEvents = ExecutionEvents # type: ignore[attr-defined] + + iface = sys.modules["isaacteleop.retargeting_engine.interface"] + iface.ExecutionState = ExecutionState # type: ignore[attr-defined] + iface.ExecutionEvents = ExecutionEvents # type: ignore[attr-defined] + iface.RetargeterIOType = dict # type: ignore[attr-defined] + + class FakeBaseRetargeter: + def __init__(self, name: str) -> None: + self.name = name + + iface.BaseRetargeter = FakeBaseRetargeter # type: ignore[attr-defined] + + tsm_types = sys.modules["isaacteleop.teleop_session_manager.teleop_state_manager_types"] + tsm_types.bool_signal = MagicMock # type: ignore[attr-defined] + + dt_mod = sys.modules["isaacteleop.retargeting_engine.deviceio_source_nodes.deviceio_tensor_types"] + dt_mod.MessageChannelMessagesTrackedGroup = MagicMock # type: ignore[attr-defined] + + +_install_stubs() + +from isaaclab_teleop.control_events import ControlEvents, poll_control_events # noqa: E402 +from isaaclab_teleop.teleop_message_processor import ( # noqa: E402 + TeleopMessageProcessor, + _classify_command, + _extract_command, +) + +# --------------------------------------------------------------------------- +# Test doubles for MessageChannelMessagesTrackedT +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class _FakePayload: + payload: bytes + + +@dataclasses.dataclass +class _FakeTracked: + data: list[_FakePayload] | None = None + + +def _tracked(*payloads: bytes) -> _FakeTracked: + """Build a lightweight stand-in for ``MessageChannelMessagesTrackedT``.""" + return _FakeTracked(data=[_FakePayload(p) for p in payloads]) + + +def _empty_tracked() -> _FakeTracked: + return _FakeTracked(data=[]) + + +def _null_tracked() -> _FakeTracked: + return _FakeTracked(data=None) + + +def _make_inputs(messages_tracked): + """Build a fake RetargeterIO dict for the processor.""" + tg = MagicMock() + tg.__getitem__ = MagicMock(return_value=messages_tracked) + return {TeleopMessageProcessor.INPUT_MESSAGES: tg} + + +class _FakeOutputSlot: + """Captures ``outputs["key"][0] = value`` assignments.""" + + def __init__(self): + self.value = None + + def __setitem__(self, idx, val): + self.value = val + + def __getitem__(self, idx): + return self.value + + +def _make_outputs(): + """Build a fake outputs dict with capturable slots.""" + return {"run_toggle": _FakeOutputSlot(), "kill": _FakeOutputSlot(), "reset": _FakeOutputSlot()} + + +def _step(proc, messages_tracked) -> dict: + """Run the processor's _compute_fn and return captured outputs.""" + inputs = _make_inputs(messages_tracked) + outputs = _make_outputs() + proc._compute_fn(inputs, outputs, context=None) + return {k: v.value for k, v in outputs.items()} + + +# =========================================================================== +# TeleopMessageProcessor: basic command parsing +# =========================================================================== + + +class TestStartCommand: + def test_start_sets_run_toggle(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(b"start")) + assert result["run_toggle"] is True + assert result["kill"] is False + assert result["reset"] is False + + def test_start_does_not_set_reset(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(b"start")) + assert result["reset"] is False + + +class TestStopCommand: + def test_stop_from_stopped_is_noop(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(b"stop")) + assert result["run_toggle"] is False + assert result["kill"] is False + + +class TestResetCommand: + def test_reset_sets_reset_flag(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(b"reset")) + assert result["reset"] is True + assert result["run_toggle"] is False + assert result["kill"] is False + + +class TestResetPulseBehaviour: + def test_reset_clears_on_next_step(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(b"reset")) + assert result["reset"] is True + + result = _step(proc, _empty_tracked()) + assert result["reset"] is False + + +class TestKillAlwaysFalse: + def test_kill_is_always_false(self): + proc = TeleopMessageProcessor(name="test") + for payload in [b"start", b"stop", b"reset", b"hello"]: + result = _step(proc, _tracked(payload)) + assert result["kill"] is False + + +# =========================================================================== +# Shadow state and toggle sequences +# =========================================================================== + + +class TestStartFromStopped: + """``start`` from STOPPED needs 2 toggle edges over 3 frames.""" + + def test_full_sequence_reaches_running(self): + proc = TeleopMessageProcessor(name="test") + # Frame 0: "start" received, first toggle edge queued + r0 = _step(proc, _tracked(b"start")) + assert r0["run_toggle"] is True # edge 1: STOPPED -> PAUSED + + # Frame 1: queue drains False (prev resets) + r1 = _step(proc, _empty_tracked()) + assert r1["run_toggle"] is False + + # Frame 2: queue drains True (second edge) + r2 = _step(proc, _empty_tracked()) + assert r2["run_toggle"] is True # edge 2: PAUSED -> RUNNING + + # Frame 3: queue empty, back to idle + r3 = _step(proc, _empty_tracked()) + assert r3["run_toggle"] is False + + def test_shadow_state_is_running_after_sequence(self): + proc = TeleopMessageProcessor(name="test") + _step(proc, _tracked(b"start")) + _step(proc, _empty_tracked()) + _step(proc, _empty_tracked()) + assert proc._shadow_state == "running" + + +class TestStartFromPaused: + """``start`` from PAUSED needs 1 toggle edge.""" + + def test_single_edge_reaches_running(self): + proc = TeleopMessageProcessor(name="test") + # Drive to RUNNING: start sequence plays 3 frames + _step(proc, _tracked(b"start")) + _step(proc, _empty_tracked()) + _step(proc, _empty_tracked()) + assert proc._shadow_state == "running" + + # Stop to reach PAUSED (prev_toggle is True from start sequence, + # so a False is prepended before the toggle edge) + _step(proc, _tracked(b"stop")) # drains False (prepended) + r_stop_edge = _step(proc, _empty_tracked()) # drains True (edge) + assert r_stop_edge["run_toggle"] is True + assert proc._shadow_state == "paused" + + # Start from PAUSED: prev_toggle is True, so False prepended + _step(proc, _tracked(b"start")) # drains False (prepended) + r_start_edge = _step(proc, _empty_tracked()) # drains True (edge) + assert r_start_edge["run_toggle"] is True + assert proc._shadow_state == "running" + + +class TestStartFromRunning: + """``start`` when already RUNNING is a no-op.""" + + def test_start_from_running_noop(self): + proc = TeleopMessageProcessor(name="test") + _step(proc, _tracked(b"start")) + _step(proc, _empty_tracked()) + _step(proc, _empty_tracked()) + assert proc._shadow_state == "running" + + result = _step(proc, _tracked(b"start")) + assert result["run_toggle"] is False + + +class TestStopFromRunning: + """``stop`` from RUNNING uses one toggle edge to reach PAUSED.""" + + def test_stop_pauses(self): + proc = TeleopMessageProcessor(name="test") + _step(proc, _tracked(b"start")) + _step(proc, _empty_tracked()) + _step(proc, _empty_tracked()) + assert proc._shadow_state == "running" + + # prev_toggle is True, so stop prepends False before the edge + r0 = _step(proc, _tracked(b"stop")) + assert r0["run_toggle"] is False # prepended False + r1 = _step(proc, _empty_tracked()) + assert r1["run_toggle"] is True # edge: RUNNING -> PAUSED + assert proc._shadow_state == "paused" + + +class TestStopFromPaused: + """``stop`` when already PAUSED is a no-op.""" + + def test_stop_from_paused_noop(self): + proc = TeleopMessageProcessor(name="test") + _step(proc, _tracked(b"start")) + _step(proc, _empty_tracked()) + _step(proc, _empty_tracked()) + # Stop to PAUSED + _step(proc, _tracked(b"stop")) + _step(proc, _empty_tracked()) + assert proc._shadow_state == "paused" + + result = _step(proc, _tracked(b"stop")) + assert result["run_toggle"] is False + + +class TestCommandDuringToggleSequence: + """Commands received while a toggle sequence is in progress are ignored.""" + + def test_second_start_during_sequence_ignored(self): + proc = TeleopMessageProcessor(name="test") + _step(proc, _tracked(b"start")) # starts the 3-frame sequence + # Second start during the sequence should not restart it + r1 = _step(proc, _tracked(b"start")) + assert r1["run_toggle"] is False # draining the False from queue + + r2 = _step(proc, _empty_tracked()) + assert r2["run_toggle"] is True # second edge fires normally + + +# =========================================================================== +# inject_reset +# =========================================================================== + + +class TestInjectReset: + def test_inject_reset_produces_pulse(self): + proc = TeleopMessageProcessor(name="test") + proc.inject_reset() + result = _step(proc, _empty_tracked()) + assert result["reset"] is True + + def test_inject_reset_clears_after_one_step(self): + proc = TeleopMessageProcessor(name="test") + proc.inject_reset() + _step(proc, _empty_tracked()) + result = _step(proc, _empty_tracked()) + assert result["reset"] is False + + def test_inject_reset_combines_with_message_reset(self): + proc = TeleopMessageProcessor(name="test") + proc.inject_reset() + result = _step(proc, _tracked(b"reset")) + assert result["reset"] is True + + def test_inject_reset_independent_of_toggle(self): + proc = TeleopMessageProcessor(name="test") + proc.inject_reset() + result = _step(proc, _tracked(b"start")) + assert result["run_toggle"] is True + assert result["reset"] is True + + +# =========================================================================== +# Word boundary matching +# =========================================================================== + + +class TestWordBoundaryMatching: + @pytest.mark.parametrize("payload", [b"teleop start", b"xr start session", b"start now"]) + def test_start_word(self, payload: bytes): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(payload)) + assert result["run_toggle"] is True + + @pytest.mark.parametrize("payload", [b"teleop reset", b"env reset"]) + def test_reset_word(self, payload: bytes): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(payload)) + assert result["reset"] is True + + +class TestAmbiguousPayloads: + def test_reset_wins_over_start(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(b"reset and start")) + assert result["reset"] is True + assert result["run_toggle"] is False + + +# =========================================================================== +# Empty, null, and malformed batches +# =========================================================================== + + +class TestEmptyAndNullBatches: + def test_empty_data_list(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _empty_tracked()) + assert result["run_toggle"] is False + assert result["kill"] is False + assert result["reset"] is False + + def test_null_data(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _null_tracked()) + assert result["run_toggle"] is False + + def test_none_input(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, None) + assert result["run_toggle"] is False + + +class TestMultipleMessagesInBatch: + def test_start_then_reset_in_one_batch(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(b"start", b"reset")) + assert result["run_toggle"] is True + assert result["reset"] is True + + +class TestMalformedPayloads: + def test_invalid_utf8(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(b"\xff\xfe")) + assert result["run_toggle"] is False + assert result["kill"] is False + assert result["reset"] is False + + def test_none_payload(self): + proc = TeleopMessageProcessor(name="test") + tracked = _FakeTracked(data=[_FakePayload(payload=None)]) # type: ignore[arg-type] + result = _step(proc, tracked) + assert result["run_toggle"] is False + + +# =========================================================================== +# JSON format tests (Quest client sends JSON teleop_command messages) +# =========================================================================== + + +def _json_command(command: str) -> bytes: + """Build a Quest-style JSON teleop_command payload.""" + return json.dumps({"type": "teleop_command", "message": {"command": command}}).encode("utf-8") + + +class TestJsonFormat: + def test_json_start_teleop(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(_json_command("start teleop"))) + assert result["run_toggle"] is True + + def test_json_stop_teleop_from_stopped_noop(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(_json_command("stop teleop"))) + assert result["run_toggle"] is False + + def test_json_reset_teleop(self): + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(_json_command("reset teleop"))) + assert result["reset"] is True + + def test_json_wrong_type_ignored(self): + payload = json.dumps({"type": "other_event", "message": {"command": "start"}}).encode("utf-8") + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(payload)) + assert result["run_toggle"] is False + + def test_json_message_as_string(self): + payload = json.dumps({"type": "teleop_command", "message": "start teleop"}).encode("utf-8") + proc = TeleopMessageProcessor(name="test") + result = _step(proc, _tracked(payload)) + assert result["run_toggle"] is True + + +# =========================================================================== +# _extract_command unit tests +# =========================================================================== + + +class TestExtractCommand: + def test_plain_text(self): + assert _extract_command("start teleop") == "start teleop" + + def test_json_teleop_command(self): + text = json.dumps({"type": "teleop_command", "message": {"command": "stop"}}) + assert _extract_command(text) == "stop" + + def test_json_wrong_type(self): + text = json.dumps({"type": "other", "message": {"command": "start"}}) + assert _extract_command(text) is None + + def test_json_no_message_key(self): + text = json.dumps({"type": "teleop_command"}) + assert _extract_command(text) is None + + def test_json_non_dict_value_returns_none(self): + assert _extract_command("42") is None + assert _extract_command("[1, 2, 3]") is None + assert _extract_command("true") is None + + +# =========================================================================== +# _classify_command unit tests +# =========================================================================== + + +class TestClassifyCommand: + def test_exact_words(self): + assert _classify_command("start") == "start" + assert _classify_command("stop") == "stop" + assert _classify_command("reset") == "reset" + + def test_word_boundary_prevents_false_match(self): + assert _classify_command("upstart") is None + assert _classify_command("nonstop") is None + assert _classify_command("unreset") is None + + def test_reset_beats_start(self): + assert _classify_command("reset and start") == "reset" + + def test_stop_beats_start(self): + assert _classify_command("stop and start") == "stop" + + def test_unrecognized_text(self): + assert _classify_command("hello world") is None + + def test_case_insensitive(self): + assert _classify_command("START") == "start" + assert _classify_command("Stop Teleop") == "stop" + assert _classify_command("RESET NOW") == "reset" + + +# =========================================================================== +# poll_control_events tests +# =========================================================================== + + +class TestPollControlEvents: + def test_plain_object_returns_default(self): + result = poll_control_events(object()) + assert result.is_active is None + assert result.should_reset is False + + def test_device_with_control_events(self): + class FakeDevice: + @property + def last_control_events(self): + return ControlEvents(is_active=True, should_reset=True) + + result = poll_control_events(FakeDevice()) + assert result.is_active is True + assert result.should_reset is True + + def test_device_with_none_events(self): + class FakeDevice: + last_control_events = None + + result = poll_control_events(FakeDevice()) + assert result.is_active is None + assert result.should_reset is False + + def test_duck_typed_snapshot(self): + class FakeSnapshot: + is_active = False + should_reset = True + + class FakeDevice: + @property + def last_control_events(self): + return FakeSnapshot() + + result = poll_control_events(FakeDevice()) + assert result.is_active is False + assert result.should_reset is True From 481d7ee8c3bbe9fae2040565a5514f6daadd6555 Mon Sep 17 00:00:00 2001 From: HuiDong Chen Date: Thu, 23 Apr 2026 06:27:15 +0800 Subject: [PATCH 28/37] Rendering correctness test determinism (#5353) # Description For the rendering correctness test, we don't really need the `env.reset()` call to fill the camera output buffers. Instead, if we remove `env.reset()`, the camera output buffers will be filled on the invocation of camera.data: ```python @property def data(self) -> CameraData: # update sensors if needed self._update_outdated_buffers() # return the data return self._data ``` This means we can remove `env.reset()` calls in the rendering correctness test to avoid non-deterministic initial pose. Articulation bodies will be at their default pose for rendering for all combos. With this removal I can set the max pixel diff threshold to smaller (stricter) values: ```python "cartpole": 1.0, # decreased from 2.0 "shadow_hand": 3.0, # decreased from 8.0 "dexsuite_kuka": 4.0, # decreased from 10.0 ``` The test will become more sensitive to capture rendering changes but hopefully it can still tolerate minor pixel noise. Fixes # (issue) ## Type of change - Test-only change ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- .../cartpole/newton-newton_renderer-depth.png | Bin 692 -> 538 bytes .../cartpole/newton-newton_renderer-rgb.png | Bin 925 -> 801 bytes .../cartpole/newton-newton_renderer-rgba.png | Bin 1000 -> 864 bytes .../physx-isaacsim_rtx_renderer-albedo.png | Bin 540 -> 435 bytes .../physx-isaacsim_rtx_renderer-depth.png | Bin 519 -> 422 bytes .../physx-isaacsim_rtx_renderer-rgb.png | Bin 3860 -> 3584 bytes .../physx-isaacsim_rtx_renderer-rgba.png | Bin 4300 -> 3994 bytes ...sim_rtx_renderer-semantic_segmentation.png | Bin 522 -> 427 bytes ...nderer-simple_shading_constant_diffuse.png | Bin 583 -> 460 bytes ...tx_renderer-simple_shading_diffuse_mdl.png | Bin 583 -> 460 bytes ...m_rtx_renderer-simple_shading_full_mdl.png | Bin 583 -> 460 bytes .../cartpole/physx-newton_renderer-depth.png | Bin 692 -> 538 bytes .../cartpole/physx-newton_renderer-rgb.png | Bin 925 -> 801 bytes .../cartpole/physx-newton_renderer-rgba.png | Bin 1000 -> 864 bytes .../newton-isaacsim_rtx_renderer-albedo.png | Bin 792 -> 786 bytes .../newton-isaacsim_rtx_renderer-depth.png | Bin .../newton-isaacsim_rtx_renderer-rgb.png | Bin 17992 -> 17547 bytes .../newton-isaacsim_rtx_renderer-rgba.png | Bin 20326 -> 19937 bytes ...nderer-simple_shading_constant_diffuse.png | Bin 1624 -> 1615 bytes ...tx_renderer-simple_shading_diffuse_mdl.png | Bin 1622 -> 1613 bytes ...m_rtx_renderer-simple_shading_full_mdl.png | Bin 1622 -> 1613 bytes .../newton-newton_renderer-depth.png | Bin 1779 -> 1064 bytes .../newton-newton_renderer-rgb.png | Bin 5780 -> 1625 bytes .../newton-newton_renderer-rgba.png | Bin 6271 -> 2680 bytes .../physx-isaacsim_rtx_renderer-albedo.png | Bin 778 -> 796 bytes .../physx-isaacsim_rtx_renderer-depth.png | Bin .../physx-isaacsim_rtx_renderer-rgb.png | Bin 17370 -> 17595 bytes .../physx-isaacsim_rtx_renderer-rgba.png | Bin 20332 -> 19961 bytes ...sim_rtx_renderer-semantic_segmentation.png | Bin ...nderer-simple_shading_constant_diffuse.png | Bin 1625 -> 1647 bytes ...tx_renderer-simple_shading_diffuse_mdl.png | Bin 1625 -> 1647 bytes ...m_rtx_renderer-simple_shading_full_mdl.png | Bin 1623 -> 1649 bytes .../physx-newton_renderer-depth.png | Bin 1773 -> 1060 bytes .../physx-newton_renderer-rgb.png | Bin 5501 -> 1629 bytes .../physx-newton_renderer-rgba.png | Bin 5994 -> 2693 bytes ...efault_physics-default_renderer-albedo.png | Bin 553 -> 437 bytes .../default_physics-default_renderer-rgb.png | Bin 3809 -> 3582 bytes .../default_physics-default_renderer-rgba.png | Bin 4226 -> 3992 bytes ...default_physics-default_renderer-depth.png | Bin 519 -> 422 bytes .../default_physics-default_renderer-rgb.png | Bin 3812 -> 3582 bytes .../default_physics-default_renderer-rgba.png | Bin 4225 -> 3992 bytes ...nderer-simple_shading_constant_diffuse.png | Bin 596 -> 462 bytes ...lt_renderer-simple_shading_diffuse_mdl.png | Bin 596 -> 462 bytes ...fault_renderer-simple_shading_full_mdl.png | Bin 596 -> 462 bytes ...default_physics-default_renderer-depth.png | Bin 5842 -> 3660 bytes .../default_physics-default_renderer-rgb.png | Bin 20514 -> 20714 bytes .../default_physics-default_renderer-rgba.png | Bin 22799 -> 23058 bytes ...default_renderer-semantic_segmentation.png | Bin 1970 -> 1474 bytes .../newton-isaacsim_rtx_renderer-albedo.png | Bin .../newton-isaacsim_rtx_renderer-rgb.png | Bin 20688 -> 20771 bytes .../newton-isaacsim_rtx_renderer-rgba.png | Bin 23258 -> 23150 bytes ...sim_rtx_renderer-semantic_segmentation.png | Bin 1477 -> 1476 bytes ...nderer-simple_shading_constant_diffuse.png | Bin ...tx_renderer-simple_shading_diffuse_mdl.png | Bin ...m_rtx_renderer-simple_shading_full_mdl.png | Bin 7134 -> 7144 bytes .../newton-newton_renderer-depth.png | Bin 6145 -> 4047 bytes .../newton-newton_renderer-rgb.png | Bin 14598 -> 9313 bytes .../newton-newton_renderer-rgba.png | Bin 15902 -> 10177 bytes .../physx-isaacsim_rtx_renderer-albedo.png | Bin 3990 -> 1907 bytes .../physx-isaacsim_rtx_renderer-depth.png | Bin 5842 -> 3660 bytes .../physx-isaacsim_rtx_renderer-rgb.png | Bin 19989 -> 20621 bytes .../physx-isaacsim_rtx_renderer-rgba.png | Bin 22229 -> 22843 bytes ...sim_rtx_renderer-semantic_segmentation.png | Bin 1968 -> 1474 bytes ...nderer-simple_shading_constant_diffuse.png | Bin 11287 -> 6893 bytes ...tx_renderer-simple_shading_diffuse_mdl.png | Bin 11287 -> 6893 bytes ...m_rtx_renderer-simple_shading_full_mdl.png | Bin 11287 -> 6893 bytes .../physx-newton_renderer-depth.png | Bin 6228 -> 4075 bytes .../shadow_hand/physx-newton_renderer-rgb.png | Bin 12951 -> 8424 bytes .../physx-newton_renderer-rgba.png | Bin 14098 -> 9239 bytes .../test/test_rendering_correctness.py | 62 +++++++++--------- 70 files changed, 30 insertions(+), 32 deletions(-) mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-albedo.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-depth.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-albedo.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-depth.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgba.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-semantic_segmentation.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-albedo.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-rgba.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-albedo.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-rgba.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-semantic_segmentation.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png mode change 100755 => 100644 source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-depth.png index 5853518fcd66e384d20eaa0593053ff3032c8d2d..fd54026086e65cb692f3589cd802925c4b2674de 100644 GIT binary patch delta 445 zcmdnOI*Vn3ay{ciPZ!6KiaBp@TCZz15MaF^u#t`H+kUgK2ST0UEj2BSdrgvO&k0^| z&}8w&qi&DNm>JN)3V$!nr6Iezr-j&U+*iWfJ}p)Au2Zts{2O~qnE!AEDt9n{vC^79 zBeqvzk(&Vfi_#D~!`)JjE@1Bc?5yj7Kaaf0Vi7cL>VSy?^{fzdxx#U2YF@%|6W`fW z3uU@XE_JD?ZF9@5ytTH!Yx3)hxpB|6KilNb`}X(udbjJj@%`tghX2(l%P(L5+%<0Y zjqSq44MHhr>*W44eR`ZA(WW$aj=|&vMuYn8OQr;QE!~~r`BeC|w|PNEqt$2WSYugyqG%Dysf`Q2YDEOyG>eO9{9==|qrnfLc={m&}7`|azGn98gD zN#D!9SH(Tq+F5ID$gbgd?alLp`Wp6pZHXF}PI3w}Pflc%75_2ce*M{E4#D^P5<%ha M>FVdQ&MBb@0PH!$zyJUM literal 692 zcmeAS@N?(olHy`uVBq!ia0vp^=RlZ)2}u5|T{W43fvMWl#WAE}&fA;2^IkcKuwD?@ z`6M*(!~gqkXAi4hx})?&F-NARwdR>cw&=BuncLi|(?p)W;oqZC^FjfA8nTi4T6h*lJzAKW6>)-SM-Z{oXIt zef00QA3vXF|C6YSubzL{xqMqva`+k6Q!aUT-;3D?e7CyswoK8JEy55r5D4)e@MqpO zvtrW!CMUZfUQ-pw3d_7IQMNN?k#f(ntjML=(Mv;%8~LKwhFuhNTfHUgkJjD1kgZ3~ zJufb45!6~6rTMhP%60jQTUC4e`V9P*hCY_uX|`mQ>b9FXKm!(J`7F7#YHHA>zpbLG z7iFVXKK>{ntJto?9r|+RF%8XI%RpfQ4y1D12mh5f?toe){d% zabM1Uf6ZBW=l9v~iz{B73(I-awbNndiM7A)K4O0r$G-cnp2|rEZ8p3C@?lQBJM+R+ Sl?OsjAa$OuelF{r5}E)x5-EuQ diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-rgb.png index 7835e4274cee397904b8d8e0a5f1bf308432cec8..cd4ffb12e9a51191f7821a3a214371f2ec3d048c 100644 GIT binary patch literal 801 zcmeAS@N?(olHy`uVBq!ia0vp^=RlZ)2}u5|T{W43f$4>(i(^Q|oVRxu`e_G>I9%K( zE?}`@u`h?~$N%xoN-WLHGEAS3BxGc(`r# z*PJNZefP!DjXV=Uyo|QA&^kVq#(N?YI29|0cfFyq5Bzw|BPG{+=rx9=#nz z5+^#|H{QEp>x#MS5-o%B4fpD!&vJNecOX0IcRbI&r)&AxTx=B4C0 w23KZT&_opoTfUc2r0CA-_152#j_{3$Lk-~Qf`ZRf6CRx@sh9q+Wg_gmtR{^Sdc zmXkj)a@2?Uzy0;>+plM3?}I-u067H~H9!9(zE}Rhz_#RA=R)(nSDr~$o%POLRVY6D zircJ;jh?+7SJ-B6bzW+Ab%*S(lB~^jTX##CELs};_m~ZcC<*gId?)?gyofVTCB>nRAbN*`iMB|l$6K^GKE_<Piun@iSX-zmvv4FO#q4sEOP(= diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/newton-newton_renderer-rgba.png index 6e28bd204f3c98965592635001a87defce372920..cf3e3dd4ddbc98d16118d1e6af336bb2b3ec4348 100644 GIT binary patch delta 401 zcmV;C0dD^22jB*fBvmI#L_t(|obA`KYTIBKhEb`cL$*X)Z76Z4-2c^dDxpg^wOfY{ z?xa9Url-b|#W|Dk^ufymzlQ+;u3=n0=6OD^c?fS!(=-l)UAAV~<-^*Cz%3GypdOQM z10@w;SK%Cvr{Qost$han*yAp{3J0!5k&rK1arv0%`Ml;K|KsV)^Si&leEWLkOD<=c zrg0eTvNg*tui@*_ve+Pc;ZU)i%mgZJU-| z?$$o!5^i~jJ=^mxo@$YiEt75oOc(&bW{u0oJkRGf58Yrd14b0P z3g>V<4Ts}t?KAkt9(UPQIB+eKu>mhzjLXM7&*wD{`5#YTp5Oib<=fXQUvfFqG>yYx zm#tZLc@5|Dr^lNe=-2O$@BaR9|L4k=T+a6&KgVIP%hoKrc&b6*skTv`YTLBza<}#& vmvGBN?Ae}o@l=D6k1vyM14$VG05~iYfFG00000NkvXXu0mjfmC4gA delta 627 zcmaFB_JVzaO89zD7srr_Id89T%oC0lak%(VSfs>2wzH+7>;Hc#Ee8(=^9z9iPePkp zrevk(^uJ#d8o92oXiKgCGEN4)hg<3*ragT)tNFwF%6a~mQ}nAhzk9kro1baB$Ha?z zllhoL>zfo0@7!H)e)sPF-QOg5a!wR1%bc~Z{&&q=L5Uk19u`=-*F0=JJX!W*sW{(k z%S)@3Wy}hTD`o8}_io^O#Kw}017*1y00{<-)*e&3Jcho0L$e?5NwvdPWU z_tsPwu5x@3a$9rxExWbVm1S=ow-uc3mbiHWBtDsu*=^!|1)!&eBpDcXm%{A&aoy(p z^2|-s_eOqyR6jY9(XC!WZO-oB<=5pJnI+X^cBnku(7AZ;k3)&Z?p^K{IgfsCKYv~B z;i4Ai#)2DH0?%z-c*ye9rEjl1p5F?1xPjmO2O}^{tWH6F*i*NTSNL3@UrTgBODvBt z-*4kj2pOQ)rM`l^4imiDTd{8LwJ846n`~l=CoHe{{qOMN`}IG+d^|KcCw<;?s2_hy zpPPMseO{U4{yO{mFTbbj|BI=Z^tR^j$Im9d-!_@Qj+>JuU~#qV)XHuCujA&$R!`8q s^GV6g>=RIY@|^8h{2~UFJ`=?^znY zWyz=Rwch*>;yro=RthSaIxTVQxWX|}N-;>;C94Iu;3h5G2hDdMZmHPmzg@{Y>f({3 zX6w&C-lV_#@!gNRYF1|5`({%<)i>)=Qr&`mdKI;Vst05JQJm;e9( delta 456 zcmdnYJcnh1O8sL`7srr_Id88UW;r>Eumd@yQaKnb-UdN?+KVS968$z-cG*y{n$? zoYTKm`A^%brCrfEtHd-5H?EXwT@kgRevS01td)=QGA@Un{*|?wLn`oc=;QRrZr`gK zE>|D1-n~#J`N%kES<33K3X5`AiRnLm?f{apy7-SbbgP`poq*8S?vJ~3BdiZFZ$A3! z-Py$LYbU9=9y#K6{rJo5IP>FcpFaEN8~oAk`ftbNiArkE8iG2QZ!rrq!`wabrAj>m z!@uhlMr&mo?Y{+DGrV`Ys-&7%8|p23-7K`b#8N)ny<+C7z>Za{r*|!U8>XAx_w*>x zS*wp3H`#fFa!=cOBc#_UL^tjNZ|)RMJI&O9ZBhJ6x0YpzEXw*A^v^kTt?=9XKvy~* zNeVXQ3fx*}Qs>;hmD$E1dd<6gGG({5Je7l7d~!-^=j?k{T6`xe@7;Y9(f7rBJ|C@a1o@&4KCA<}Z6kHVdc+4V+8wF+8>+u25Bd|Bby>4F8?7T0D9M zRthSaIxTVQxWX|}N-;>;1*hPI=&udTx3|5GyH=_jStL4r#|`bR*P`C`mGAw2_q5G< zRc}7s(?&aQsLvDs+$i*j`7f&;8=3LB=-dFedOg=x3T&w!@pL7PC|A3_E(JT z+j4L3UGq9>(-SSd!j0isYq#E#eP8u{_w>qn-qU2Frll5b^taJ}#;kgT{TFu(4_*gZ ZY-Ep+@~{_OmvbMa&ePS;Wt~$(6978-gp~jQ delta 428 zcmZ3++|DvVrT(0!i(^Q|oVV9EW*s&VX$#yfVe{j!{MF`z3;0e7IB67Ss{TD?xp?{M zr`>up)fmu0L)_FLFU_^fA27ejk%(W*{$SIE>2X<=&nok559NF4hTb`>w$-DPfA*CX zE?EaR<*m39boB0pb-|~9T{RQjvS?lKHsQ1re z1yikww-kQuUl=6qc!YQAEeosq8&!Wy-~9gfCH3|;waH65Zrrfl`q<*uxsNYw&M((L zy~$+elclAx>e9O(Z8Kz_;w5uI@P@?1hw>9Y@YjFX4|J*fgZ|pBat;29KxeJr2XxL= zpaVbKY?pgzv@)>sN>l3H3w6P#Zzb0jns2#(rNaFJe-O~&a%LdON33@*_}zRkJ4ozl zsI;R?ZqQ;OkSpc2wmN>PcV24N)Y0yn6{57Yu%ynpx~AfLZT+uJxzUq7gIsdn%)2FD zD3klVqGaCl%hOXMXZd`(v}@hu-q^z1Gnlb%7 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgb.png index 9e3e93fe051ae4b66a26eef02b1dbe3fdae76f05..6a5e2221463139d05ea860cf20bfa93814c07355 100644 GIT binary patch literal 3584 zcmcJSc{J3E7r?(`hK9r_Jwukk@QjeHXJs2B_lKks*b|NQ=Xzdzpl?>+b2&$-EVHs_BBNC*G` zaKysg%;CWH{wYwN1I>x0W*zuDEX+(DuNSZit-~bit|d%~q5A|;UH2D}uj?{BmGf0r z<@Wav`Q2kbf9+IRaZOJb7o4jPw2}8XT`E$U zHwblMT#hfF*M9rAgul4dve+B5%Xb`BC}56H{p8%%0y9`^gcfMh{OU(&l{{yN9Ha5s zgSfp|yfH+Gi6c7gZtX|y5BBA>=@%ADeP4~)rR{Gvvh%8lPFY_E`^2213QM@(4l>?( zKM~wxE7i%}D0f%e+4Kz5`Vq^Ze{g}mNzQl-Cee;T~-Yhrrmt4E{D=w_;ggu~J%%04GF z&yp6&D^-5KCgU-a&Q5@6o#>!2Q`qJ1P+*9q6V+(?Bp1oy-T1@2yX`8?&POdc-u!^i?6+ZG1ak|>bdp|6@VR99rao>A{_uNsuG%ZY$^fRW{R5n15$+tA|))AJXRhzlTP97~FP6cZ-^FO!pQO6!jaX8E#4jR~e=r+>y6 z*|fCO-?VLyCANV23!LVjjylVO_#ePXdCaI_w{E(^_zh72#si{cdsw2*`U)*D%Uy2n zJ2`Vg3F5wTr%TK~zz{teq=%v)6 z6p{tRl7W_rRBaBs=4{O=RPSEeLeqaZjWcrUFc78V>18x1&d)VNgoNz!D|X9B(SpjF ziX>^Gc?7%7&UwCAy6oQ;=Z1^%qL&lks>ab(oLQ5C*!}t0y1us-*NdD5y~TVUWn9=; zFKpBVG)YL`STO8tX302I7U0wDcU>DnC+$u(Ul`7X;x0eoa|>u|aszOCkB!~R*uAx6 zWGfV@#BU!abEF#$617v0l+IM&7eQo_J!Xosz@HT!>MglXnw?Z~wbk=8jILA7y61@! za}oO)Y%{2K?(qHOCqSmR^e_TQ!UDNprBk0bgABzp26ZfpHK;4_Ol{4F39dP6 z$Fls|k*rR#64bsMov8`;mY@46S23|{__>N3rt)JqV2@_sGP zl8xhsJa{B%HU_PXw-R#TJA~sos;w;eBAI~m48KPPL>YIm)W-Z}({rg={K`I06lF2u zR%no%#;MA{>JgOd^vXdtrnE8>HS9d$HXDR3~KaMu(R6SYtzmLv^aQtn#t(DIx-(QbO%y zfxEp1_yH!V%Gk49i)&X{Y=51uI$A0-Z1g4*yH3h3V()Z~FIncu6$SmWzGumHFbUZu z-Wj=BJ{moiU za7k?+d}2XtjP<>5trD)!aBZf>?!QZ1+ne>Q3wwEHvaxyReI$*sF+~1o+f1GFG5K*l z+MK&76j#`tY{OYsvCQm%fzh{ zTq++L{q69VTMw>^cc^UhZe+x_z%l=!>{vVDJ2S5Dac|^4g0V~BRicy?LkJ(0E4bce<^Zml^Z9I=mZ*{5dGgW+bMOd*N@yNBbfk|}I zs$sDsQVM(j5YnhYKZE~wNE5-5rJi*;w}i%0^>X-$hjv`s4WE|vFAMKPnD;)-{utE0 z<7D0P!e+vFcQyT`rN-^xQ{c;cS9ZcI_MzySm;&rg*HiPnR)el0ZPQ4L|GF{p0FE*x71-dw^o zp8o3WDpOG@!Mz636&zTZM#Z~IAeL|PdxXEy$a=e%hSWj{ow`t!GPsp9S6;J_PqUdM zer(dUI5p~WQW*|?0!1u8H-g$Jr$4x-a6QupT0rp}8PFB3K8GOL%fTGwa{&1xFtL-o zS)niK^ZS)j?he`pDx`rXa^Ak4#_l&c{$aZixy)eywu0%X@0m|-wN`RBqaq`A<@HLWgyUeJjgRX4B>h~8PKH_RN~CA zrM8PQ0;u8h({&DF{~UiD^aS^?I!*v6x1g~2^VY@hi=z;BPlXm30xfwMRnGj!v>Pgd zfH%5q9F##=1uJ@EY%U9SiJ7ll?lq!s8P__GoH;^?c`6D#LdZjGKjC3$2(a+s)0wVCFe z$s8iGFZI@Pk`P*o4fQ=TG!|zxe~~+!zqK$`@a)C7<+7*_>iOGrA^nC#(tQnq^B2|! zc8IN&vk_r)vN;g7MrI#3s=LA~Lwg~HCNb0X(MFa2c+ae$1{gA(v*b75bH{r~pTPrdTm2R&P#vmOAasuZ%5k z<=}sR_uf0K+M@i3_&hX6cTKye_^068(FTE&H^Qbs0G9e9 S*UZ6Z2Uwi7F{{Mj;{FY-6wG4) literal 3860 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 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-rgba.png index de26933c492f7e23f414cb6e049721ad047f43ab..c679cb05e9b3ace37901988bfa1ab09a503498af 100644 GIT binary patch literal 3994 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 YZjBO$CZ!=pN0}SoXy*#AvkAid8=qo(4*&oF literal 4300 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 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-semantic_segmentation.png index 013c6e0cbc954a9bd414297eafd0d6be7e0e501d..a647622781cd4636b13aec8defd7d642a2a5e8ea 100644 GIT binary patch literal 427 zcmeAS@N?(olHy`uVBq!ia0vp^=RlZ)4M>*B+a6(HU@Y=P z|BEkK2ykRhYgn7icEww!`^t@MMxc6Z;LV##y?3d1V&uZ}8Ol^-I9iw+3ls$SSPnHh z0F7X0dg!2lQ}AHnSGIpou77_Md~W;djm1B04y>53$@5`e@6J1?H%?nFrE)ud=JJjH zO=o9vW3!rgv6{+x-PMxj(~n=3Zjj}~?iy=cwTm24-!g+I%DGdZF7855BX%6Y?DBroDWJ5jP4q{GwI&t;ucLK6Uiop7iC delta 386 zcmZ3@+{H3MMg5|ui(^Q|oVPa|`wkfhxH`7~zCZJ30}HoU^39EZcY7txU-9*lZWQyx zi{kYjA8P(K)C=$i+lJXQ2%gHVcxZ5F>o3N4YBFwGn@d{`Yb@Q?SFoVDKw|BYoglH| zb*gQ_kGi+-{2{5*b~s8EB0EF%UyOHhLUqTy=HA0T8$WWiM=tuWBEVN7z_;0TUiP8H zWjBg_E$%4hyv#RB zF^FXc8i)!4=ASGvn|aP_eTL^#ap~!D2i8rHQVddd$!hWF6<8^#Wa_lUt>X&EM4W;t zAyo$&7GK_c@AUE0!4p;2W;{yTzw&Op{PepUUw$mz|GlX2Q*6q~1*>;&KX>$BQmvtV zfYTp`?Ty`xsO})d`=7Tj;+**DOWILEKaU<;%DzFHfVQd9&w*6mCt=*Kn8#BM&t6!OQ@8HAK@S982^q#SMb;!RkD=@_CrUgm%6^!Of=C}syVGV_O|g^d2mb;v3yh3w`bWxI1@kS%alr?LVW>Pu{%>pQ@s%r1mXltNi^k z#n;a&UjF&@_{`+Rd)u`-Vfrv{@{rh;wmwn*KOOPx300ACSLA*d#zMA$1kT< zQ5)7AU6v)XDC=WWUdCdd0!Pi}O;@V~E`q(;J^hp1s;s#g|JznY@hgeVUKQA}>g(c< z(!0W}56IltUuNv@#|Ly-kcx{>&f}Ml|D8X-%Q9})j=8m$LiVhE`#3OpqLSLPhM*4S jTg<}DK*!38OMT;ynx7QKKKK1JP+WMr`njxgN@xNAUG3b7 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png index 1c4916d96df5a4f7fd3353d38b8a5a20f65b8d91..effbdb581a9cf68706adfaaec1df9b15865ac64b 100644 GIT binary patch literal 460 zcmeAS@N?(olHy`uVBq!ia0vp^=RlZ)2}u5|T{W43fpM~@i(^Q|oVQmEy_f?fS}%GQ zur`Y9VrgDF?b^zgFM19eMaw6+<+zGZ5RHh8*1Ex==eRvlu-m{V`C;vQ)#oP6>v#RB zF^FXc8i)!4=ASGvn|aP_eTL^#ap~!D2i8rHQVddd$!hWF6<8^#Wa_lUt>X&EM4W;t zAyo$&7GK_c@AUE0!4p;2W;{yTzw&Op{PepUUw$mz|GlX2Q*6q~1*>;&KX>$BQmvtV zfYTp`?Ty`xsO})d`=7Tj;+**DOWILEKaU<;%DzFHfVQd9&w*6mCt=*Kn8#BM&t6!OQ@8HAK@S982^q#SMb;!RkD=@_CrUgm%6^!Of=C}syVGV_O|g^d2mb;v3yh3w`bWxI1@kS%alr?LVW>Pu{%>pQ@s%r1mXltNi^k z#n;a&UjF&@_{`+Rd)u`-Vfrv{@{rh;wmwn*KOOPx300ACSLA*d#zMA$1kT< zQ5)7AU6v)XDC=WWUdCdd0!Pi}O;@V~E`q(;J^hp1s;s#g|JznY@hgeVUKQA}>g(c< z(!0W}56IltUuNv@#|Ly-kcx{>&f}Ml|D8X-%Q9})j=8m$LiVhE`#3OpqLSLPhM*4S jTg<}DK*!38OMT;ynx7QKKKK1JP+WMr`njxgN@xNAUG3b7 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png index 1c4916d96df5a4f7fd3353d38b8a5a20f65b8d91..effbdb581a9cf68706adfaaec1df9b15865ac64b 100644 GIT binary patch literal 460 zcmeAS@N?(olHy`uVBq!ia0vp^=RlZ)2}u5|T{W43fpM~@i(^Q|oVQmEy_f?fS}%GQ zur`Y9VrgDF?b^zgFM19eMaw6+<+zGZ5RHh8*1Ex==eRvlu-m{V`C;vQ)#oP6>v#RB zF^FXc8i)!4=ASGvn|aP_eTL^#ap~!D2i8rHQVddd$!hWF6<8^#Wa_lUt>X&EM4W;t zAyo$&7GK_c@AUE0!4p;2W;{yTzw&Op{PepUUw$mz|GlX2Q*6q~1*>;&KX>$BQmvtV zfYTp`?Ty`xsO})d`=7Tj;+**DOWILEKaU<;%DzFHfVQd9&w*6mCt=*Kn8#BM&t6!OQ@8HAK@S982^q#SMb;!RkD=@_CrUgm%6^!Of=C}syVGV_O|g^d2mb;v3yh3w`bWxI1@kS%alr?LVW>Pu{%>pQ@s%r1mXltNi^k z#n;a&UjF&@_{`+Rd)u`-Vfrv{@{rh;wmwn*KOOPx300ACSLA*d#zMA$1kT< zQ5)7AU6v)XDC=WWUdCdd0!Pi}O;@V~E`q(;J^hp1s;s#g|JznY@hgeVUKQA}>g(c< z(!0W}56IltUuNv@#|Ly-kcx{>&f}Ml|D8X-%Q9})j=8m$LiVhE`#3OpqLSLPhM*4S jTg<}DK*!38OMT;ynx7QKKKK1JP+WMr`njxgN@xNAUG3b7 diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-depth.png index 5853518fcd66e384d20eaa0593053ff3032c8d2d..fd54026086e65cb692f3589cd802925c4b2674de 100644 GIT binary patch delta 445 zcmdnOI*Vn3ay{ciPZ!6KiaBp@TCZz15MaF^u#t`H+kUgK2ST0UEj2BSdrgvO&k0^| z&}8w&qi&DNm>JN)3V$!nr6Iezr-j&U+*iWfJ}p)Au2Zts{2O~qnE!AEDt9n{vC^79 zBeqvzk(&Vfi_#D~!`)JjE@1Bc?5yj7Kaaf0Vi7cL>VSy?^{fzdxx#U2YF@%|6W`fW z3uU@XE_JD?ZF9@5ytTH!Yx3)hxpB|6KilNb`}X(udbjJj@%`tghX2(l%P(L5+%<0Y zjqSq44MHhr>*W44eR`ZA(WW$aj=|&vMuYn8OQr;QE!~~r`BeC|w|PNEqt$2WSYugyqG%Dysf`Q2YDEOyG>eO9{9==|qrnfLc={m&}7`|azGn98gD zN#D!9SH(Tq+F5ID$gbgd?alLp`Wp6pZHXF}PI3w}Pflc%75_2ce*M{E4#D^P5<%ha M>FVdQ&MBb@0PH!$zyJUM literal 692 zcmeAS@N?(olHy`uVBq!ia0vp^=RlZ)2}u5|T{W43fvMWl#WAE}&fA;2^IkcKuwD?@ z`6M*(!~gqkXAi4hx})?&F-NARwdR>cw&=BuncLi|(?p)W;oqZC^FjfA8nTi4T6h*lJzAKW6>)-SM-Z{oXIt zef00QA3vXF|C6YSubzL{xqMqva`+k6Q!aUT-;3D?e7CyswoK8JEy55r5D4)e@MqpO zvtrW!CMUZfUQ-pw3d_7IQMNN?k#f(ntjML=(Mv;%8~LKwhFuhNTfHUgkJjD1kgZ3~ zJufb45!6~6rTMhP%60jQTUC4e`V9P*hCY_uX|`mQ>b9FXKm!(J`7F7#YHHA>zpbLG z7iFVXKK>{ntJto?9r|+RF%8XI%RpfQ4y1D12mh5f?toe){d% zabM1Uf6ZBW=l9v~iz{B73(I-awbNndiM7A)K4O0r$G-cnp2|rEZ8p3C@?lQBJM+R+ Sl?OsjAa$OuelF{r5}E)x5-EuQ diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-rgb.png index 7835e4274cee397904b8d8e0a5f1bf308432cec8..cd4ffb12e9a51191f7821a3a214371f2ec3d048c 100644 GIT binary patch literal 801 zcmeAS@N?(olHy`uVBq!ia0vp^=RlZ)2}u5|T{W43f$4>(i(^Q|oVRxu`e_G>I9%K( zE?}`@u`h?~$N%xoN-WLHGEAS3BxGc(`r# z*PJNZefP!DjXV=Uyo|QA&^kVq#(N?YI29|0cfFyq5Bzw|BPG{+=rx9=#nz z5+^#|H{QEp>x#MS5-o%B4fpD!&vJNecOX0IcRbI&r)&AxTx=B4C0 w23KZT&_opoTfUc2r0CA-_152#j_{3$Lk-~Qf`ZRf6CRx@sh9q+Wg_gmtR{^Sdc zmXkj)a@2?Uzy0;>+plM3?}I-u067H~H9!9(zE}Rhz_#RA=R)(nSDr~$o%POLRVY6D zircJ;jh?+7SJ-B6bzW+Ab%*S(lB~^jTX##CELs};_m~ZcC<*gId?)?gyofVTCB>nRAbN*`iMB|l$6K^GKE_<Piun@iSX-zmvv4FO#q4sEOP(= diff --git a/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/cartpole/physx-newton_renderer-rgba.png index 6e28bd204f3c98965592635001a87defce372920..cf3e3dd4ddbc98d16118d1e6af336bb2b3ec4348 100644 GIT binary patch delta 401 zcmV;C0dD^22jB*fBvmI#L_t(|obA`KYTIBKhEb`cL$*X)Z76Z4-2c^dDxpg^wOfY{ z?xa9Url-b|#W|Dk^ufymzlQ+;u3=n0=6OD^c?fS!(=-l)UAAV~<-^*Cz%3GypdOQM z10@w;SK%Cvr{Qost$han*yAp{3J0!5k&rK1arv0%`Ml;K|KsV)^Si&leEWLkOD<=c zrg0eTvNg*tui@*_ve+Pc;ZU)i%mgZJU-| z?$$o!5^i~jJ=^mxo@$YiEt75oOc(&bW{u0oJkRGf58Yrd14b0P z3g>V<4Ts}t?KAkt9(UPQIB+eKu>mhzjLXM7&*wD{`5#YTp5Oib<=fXQUvfFqG>yYx zm#tZLc@5|Dr^lNe=-2O$@BaR9|L4k=T+a6&KgVIP%hoKrc&b6*skTv`YTLBza<}#& vmvGBN?Ae}o@l=D6k1vyM14$VG05~iYfFG00000NkvXXu0mjfmC4gA delta 627 zcmaFB_JVzaO89zD7srr_Id89T%oC0lak%(VSfs>2wzH+7>;Hc#Ee8(=^9z9iPePkp zrevk(^uJ#d8o92oXiKgCGEN4)hg<3*ragT)tNFwF%6a~mQ}nAhzk9kro1baB$Ha?z zllhoL>zfo0@7!H)e)sPF-QOg5a!wR1%bc~Z{&&q=L5Uk19u`=-*F0=JJX!W*sW{(k z%S)@3Wy}hTD`o8}_io^O#Kw}017*1y00{<-)*e&3Jcho0L$e?5NwvdPWU z_tsPwu5x@3a$9rxExWbVm1S=ow-uc3mbiHWBtDsu*=^!|1)!&eBpDcXm%{A&aoy(p z^2|-s_eOqyR6jY9(XC!WZO-oB<=5pJnI+X^cBnku(7AZ;k3)&Z?p^K{IgfsCKYv~B z;i4Ai#)2DH0?%z-c*ye9rEjl1p5F?1xPjmO2O}^{tWH6F*i*NTSNL3@UrTgBODvBt z-*4kj2pOQ)rM`l^4imiDTd{8LwJ846n`~l=CoHe{{qOMN`}IG+d^|KcCw<;?s2_hy zpPPMseO{U4{yO{mFTbbj|BI=Z^tR^j$Im9d-!_@Qj+>JuU~#qV)XHuCujA&$R!`8q s^GV6g>=RIY@P5O8rew7srr_IdAXo_LB}2X?^JHGF8cRtFqjIm6P|_Tq&&54&LqO*gh$# z>FH}l=56PrPXE8IAmFt5YjIkQeJI1Nykq+HzvPaVeBQk_zkc1Spk+TUJPo+uAY`dB zF?nXnbCH}^H$qV>^Sm7wR_`|D<&FR%NQym7`a_h;pE@7q<~(zIGX>G;cbx%I`9 z)>r?JH`w=m-`^Q?U)%ottNAPb-u<$7Ty-it{x5f4v%dF#z2}|zDH3h3td3qj?fK(x z@E3ohs|WwDdFQz-%je?%BdxM36O{|QU;qC5x8;6ao!#w5j~~;YEPEeVuQUDe_58DD z{HIex&-UHF_R8j{Nbl`#q17pGA1*y>`Z!AIxc*6jkGG~A6RGojG!f*12(^>@<^Tyd zP#7*Qeg8MK*FCsr%kqS&Cx8Jo=|rJd5wG~9PhLz6p-+D-S}3^t&x0ZlrFNAfoyjTh zrzg%-k@kG@L-nLx_SED_74?X?P?`96y2_`;Ic7gqJJV;Xlp4>Rm^vviOX9JLa@o&= zH-S-DJ84h9%qIUy$E`i@32DAonP_{;%2Nwyqwl1uYg+eJ-h664ZCLI8UJa#?LN*hm~=iG4G-=gg2#N?Xyb7Sky6}QILm3{eN!cq1!GI{#^ zc{iRO=k$C6)O$?NKG-BLGu7vy7AUpUd2ZUCs&QQ9Bg`l9en9UigZyh)9rjVmEFFvY bL%)jWvETV;d@N6q0SG)@{an^LB{Ts5i@JBH delta 768 zcmbQlHiK<~O8tFL7srr_IdAV;`%4E(v^-Swa-BHqR)_qCm7f1>uH;m8uZ>;q(LO1u z`RQxU*K7Ws?RlOlz_IY}m!h&-dk2>-cYEUNuFD-QxqJ88cKfh~(<1Y|_*A7PwfIfC zFwtjIF_&0r4%bPQdy`uF;)LFB7899#!&}J8(@EX)g$n0fm4%Dzr!R>Ql|LvW75qYV zqx#Gx@s?|*CI@?{N$zYD^fP)D{-bjD3cIfVTP<5_gzPOb^5oa^{wWU$v-ao zJ=*S~F!#tN_9c^dcnMjp@{Bnjb;0+O70=angXZSo6>5gxcK%}U`~GzLVW*q(l~#jX zCFqy*A%&CAS8>jfsbxZ^>suGiJreZsw)_6FyXXAle$~B_fBCNKzMa*3kx12&NhxyY zFHb*e`1Sj}`M>`a=0B`I@-4aS==Uf7(v0oPR>Z6J$Ce-3KjWkF!LRfAHtsX4`!i{W z{Ye9!f|-l#PKL1m-+XGa&(#@={v8pu1_sGrT|I{T@^Wf_zTLikldt8_eHHVq_3j&E z`r`MS-nP-5&LDO?KU>t_(Dm^*rK4Jr)m8b$XNzK{A+qgNmOmcBy2&lPuL1XuyBTvD>tvvHc^?n}Y z_^3yZkm*N0EzU7}35tv(rN%QSrc9ccE%8ug@taS=>C;t8fWcn-WCAcQZh8Kh#<65_ z#J7eLp>SZtn0CI79l`L-wo?3^VI3{cT7-Os66M$gOfrj)8#pDdFqIFl?i#@ ze45U7^Xc)JNoSOI|6!=S`Sf|srqk#9e)+eb(6i6J`E+?u+0Vd9DkYOz_>Sw@2b<(& zR{Bf~1*Mo5svqx7W9;&LqY6qoK)?JhQkr=rdeQ|?A*-+Fwfu^BXB($X1@VCH(L8Ay hiV_pU2lg#ikF3lN1?^-20#8>zmvv4FO#ntba`pfK diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-depth.png old mode 100755 new mode 100644 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgb.png index aedccbb16056c0aa920ecba041bdc1d7ddcc7b2a..239700a5d10ddde58b9c99d7a84325d21d0ebba0 100644 GIT binary patch literal 17547 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�*X6=~pO{4up7_&lm5%e{Xht>_j21;^u0;gtA#n1|hzUXCS~v6~ zlI-v8we3n3wwVN*!9=RUiMbFe02o7nF^sV70mxNJXuNfms! zcgMjVs|MrjRpSJ@ijcAWFl_IfJ3Kl(KHTe9^S*CE+_g<#Z63XI>pk%zDgYV7)>sgl zrlDAKS2>=$8$=}bhY)$q7=yGR&oBeTacL)OirB)cTZ%tfMdFF0Ox}xvLao4rejm_J)Zr*zD-NXI8zVF#IY#LRp&%HnX;Qg*$&1T2L&=VQcOo$*}hG7UR zU#+!l?4)U!(O6@MLQNlQEHO=|Q)af-587d@M!*=(iY|l%##m-bF3-S>TU$v%GkP3Bth=`f2 zwV?tNGa74+ZH#FOq)ZeM5iibrab82YA59A<0!dWFdl%07CgN_@ zE*8sS7`*eM@P1H*(sA} ztZl5d6iWYatf51R4}n-$D(F@K1PK+J8f%TU);2`sT<@JP873wLNjR)oG@e?kDk5Fm z9v&V{tliw&(au9X>)=(g;<|t5z3Ju_$o7Nt-ccAtq~b(O#t;Lc!jKJpXqrGFW~7S~ zW@DHUt2D*xEQVv|wdT|b5NLWfEqAFgz_;8wQJcL>m>SbuHZI~dw zM=15chG;zZ-gnEPU6PrYW+HtnZkTROj4`qPjd;V#q)bXs6|Ww!M%9Z)s*Ir|y%@{Z z=Bh-+#lMMxnm|{s?6%_|!6%BdjY{awYtEv*K$^>j;7%zS5h552`+75jOo>+pW z${$|Idm;2BuCdlO8?nk*6c=I=5(nu-jVA?xjrHQA&!rj+5;IGzl4DfDaXEmJQZiCO zD~WSXI2BSi##*)t67kMC?_4-JfQUiDAP|KL#Kzhy*RDQ#@P0V8mW<(;aHvQ!wC&dR z_V)P;-n+&8xbIr;owZFUx0cp3Gr$TGh4X^0W8=c1l7@L>O$XP-NN z`O4AZVc)fbbD$9BQmm{*V#Bt169b0eFnMtj$_zDDMLk?Frf@&UPKh|7cX5pcmBe|M-N40=zDR!NF>Q>wQSpVF<*T42Y+aDXXp6%&=0+)lr9^i z#u^5J$t$xE2&|M=U>n1s>SFMt49Aj$2~4PLMkV(}NNE?%Yoj6LwU1L9WI}~Q!^U~H zn6v6i#gjFcE}Yxkm@*rH#Sf~;#h;_d4}UAHG~Rm=RgKGJ z^4x*uWdZ3B%~VY=u~6~kDU+dOSmKp>NkV)VETDv4Z{COcQK4)iGdIoD8neA&H@CJn zcP@fhTn8dHsXh)d6Sb@5o%e1M(WT2*H#fJY(}^(@laP68FRC(4STvOTv(3a<8;UJ* z@Cqm~bDH)+0-BgJAWeC!h`@`8bA8{fR*U&^Iq$m;ipgYh{`|SE&FS?|zp}Hl6Alda zBHj;vXh$SrW4!Zyx7yp?z47*&TiZL&y>uPH9a0g^mAo_|(d6!>B^j9lC8b)$T2-u) zsgiyqs-&n;z+TyldhhzST`reC6sj{uRGY?p{zrcdAn{&&uYSmeF_yOi`mTNQ@S(Mn zOINN=ClhNzURX@jT~rEMMT&rk**0t%W^0U%mGDAg#YDy`&7EF32T>G&X8sDkZ{N4e z#e8;rxLPf|cea^MHn%TaxbUZc;TNo7@lInUoN*ZdB7$ke``Pi~;lcjSx$|4w+gG1@ zdVlwcxPfdV%ArmF%^4A;$V^3=`eH!cG!3OHZsL94bsN*h z4V@~iorrfsw@P0yfwazh?_J-oCXrSIG@T)A?2V`FR8_Ce9(#4>FPyQiG%_xGMW^~|%A zjp?~_=eyNvF`tPCQ3KUf)r?d~h*{&h-)QflP$5mcGoA~g{8u^3FK2!r61VEaBn{JK zGBLI>#u#IVp$DjISK_@FznmRFG2OaAI`ra8eo%r_K!cZ6H(U(vDX}pP%6z0y3?HL( zR=lA>Yer+4$6bpQWVz6aQtOPCEF>{o6QZAK(@aIxdDnMgNd4jQ%rsNeY`}ZY)dtFD zhGYQ=(yo?w-+T85Ui$=r)ZVg|!c&T?XnQpLIklh4SlarDZ7nkV|5kH$9-?@3? zwa@?Pv(G)>x69>X9xJn|0Ll~&ElNFBsE9xfQ?>^=>;qIKBxR%9f>=mWRY7bvrW@1g zv}qb+t%`c_uJ66qzVAXv;+%8NiE1;MOq=QDOBb2V#>CE!51s3sS5V$xLh(>G6;)X+ zmj`=Ko`323#Vc2KA9mh5C_$^;i8W)ZLL7vkRz-wB$|27dR|RYGY4XmMC?>?IV1Ttv zsJ9#o1N6O@e(3ss=)7~@yP@xi0U8A5`|V`9b@9@b3zx1=CKIS{raLNL)|S>$+6$(D z3=UW==CfNjZ+zynpL^l@E4SbIZa=tG@laKTl%ss8C^$LkK@Y`bX*w1?v>&u{%*upG z5fK&DAQs|%T0rE7p?BUzq7p!0X760rwp}Za9z7-{8fVAyu z@5!UfSFc^ZdTsCVBM~p0Hi|$+JQ3Kis)h%EbKNW6=ItVxc2=34RVH1hqkeH zI-QzkVvI%12Xn}xd+$S~S@9xiMmhyKlp^26zu5}~_0GTX=DUxd>>eKDD`qe=444~Zpb8P+x2^ZixnXl_iYO8!M{L zc@du`oyRd&6*sOsp#TA)rg3i67(1Cvhkg)IGDhQKA7(Z=TaXo3%mIY7LrhzHi5YT% z24)X7AQumy;7a95q~s8WHF>eg;D1cOEd@Pilk-$0000bcg@3l^`<+Wv-e&r zKlX6;x$nJdN=E+icJ+Pto_mJ%?KSSThXs7^^}W~kUeA6}{PQR8+y;mUOhiP?!~}qt zz|2e_qWBZi=X9pwbO%khBO(A01%PxzKmbvNLP7Dkswx0Y7XtD7IWJYjI{>N>k>C8k zKL54fe&fRrL*GLczw;gdeB+(l^pPkyVpvB(1l6-q#0!Za3Q2TVD0G1T5irImRQ0$1 z-~ai`U-&=nyz`)2X%Kwr1_0PIFO6+S1W+O_KUGO7^Dz>w1WHIKo<$J`kdP@7={k_L zPW%{%=1bQR#xEk_(Vd5f2SNmEp^|nIeUvruS_BE1R<%8hsm#fZZCW4ak> zC3d6VROQi~H;(q6_I*JBoMwpvv5BcVq%@$Rj3rY>3MD$#AV4`&0MXNGQEqY;mU!#@ zgATYAimsclmb~i87`24p*PfhFsG{(qMnNBmVrYQ+%%~+}atpoR1qu}QC=|~1i>Egi zhn;1pWbz72k}hXLh=zAXq*k*Q6wOryk!rqFDT38#oIp{tfgq~udXVYOM3?3&XHlkJ z2eXir%XP=IqZv2MUP2{483h#-bS4UgCUO!As3wyP%`D3xKMF;HF-B3C5>Zr)C>QB` zc5ra-4ngphwrEMZ11QpIj$3KQKCj{u}fL?9(3ejrm%=@S7e3ROs^q?ouu7-}Tx zoGgi_phrlm;e*$IZSD+hU2&_&o!0_}dF_%jP^c=Og`$#XBGoLERBTi&EvML$QP99M zeI^R;-5X#02YZi~;$aCwpiF!*TopSq22{)^D1kU0*7WR1wxSs3s3<@siUW>}I}@ta zk>oc9zOP%~0p7{so%#I9Y~kHhFZ!$b0? z`r@o|#Rsn2>gD|4_22*6>fqST)i9a{){Lm90YV*{ME;OczGvpg$mSH&eHsNyOfYbV zsJdn#v_^5t_oxDx&+gy&_B$Z8EtX4!Gki(KWg_HQcLWuxnp9qggX6Z&LWseh5fJS| zlEbmH(Bx^?whYMt?M2EQ1j&G47#u}k&MOLy#QNUvm zle`E9HV@<_7EqD)+7W9$jcP#}q?JmPFfLH?_yA%h3L!wCp@LBHqqe?Hx5;W!4518k`(Hd>bN@hQupR;3b9Bs3NWblR`;J zNhP&gYTQb^qu8%CXzk|HjVSAS2AadOu{hSk^(-F>FdM>xRAM6-L)ws712~*@;hIg& zf?kNpghnC@icD&0+j7?&#zaR{dC@Zh%2BJ<)CBKLIA+4u7IzDAUhA;ZR#y%FJU&<} z5ks*dgQA0qSR_Z2gGbE)XzdL%(rA-)-1cYpw=g^|eqpAG_3%PL6cA!HHqbP(WX(Gb zy#Wl0KY%=Cq-qq%EISWH6;GdTbLeo(s<|=|#!zE}0?W`Gt%5+zgunp&h$X2K|dvKtrh^*2#il0z%h&M-Z#0$Y7vSIT0( zDaVpyksafybmH`{BMfW5t6HXw37*anJR~6K(1?nm%RY*O=&a9lCW0LNyaCOUOq&xUmFFYEWpXR5Ky zkNmY?o^H~_fTKHM7<2Z;n%&^pD5{gslb<>fG8m(L^EC>;>zCT5`H{c$i_CN~!*Ypk zg?^>oGTo!@WmO?gwxCJgX)swy69@!wO1qYROtREk-YeLJ@S6H(N?|-%bB{A6a<(sj z_J@A*``8jO$fSSO`2|W9%|9CjosR+|6v?!$N5>yw;!dL=;%lGy=})|NK?EVdc`SQ$ zIY1d$5~<;wlB*zLkR=+-M@P@XVKv{;dC^s*97SeYfBDkKTXtpYA`tYi#qM{#U>C|0jxb zA*>(CRMJPG5Rt>Z4-THZxj4S{Yk&7wW(#p1U%Ly8dheCH%PwxDof%HfVy(#wo&9+Y z%rkx}^pA)bCG!bTBHFreWkW8izB<`_?biFhcd-8e_$`zc9N+oRhGHyBy3UJpRKMoM zx!R+oP-eb-?X}&@FNuWz=I>wsC;#hz_xQ;ScinKsTQ~2WB|8JfL&ttyvQV)yUpD-Z zzl|fe`UaeDr+`Z8Gg0BixqeesRaH)Av%c>>^MjuT@Yao}CF>~4)4v-OWA(z?+nFeG zc$Rw2dMc!>!{SpIM8qE)JUKaf@)tk%ucPk0XL5%}Rqy#H0s)2rA~VPun5bxLeAkh5 zqqI!S+H%#`F-%OPF;&A9%1|niD&!otsK%@!x9x@x!()nrrZp59Rzbl85ZN*+K`=xz zS4!QB;ot5%JLNQB{bjq%jzl^|9dkhNNVe&BaUCcP?Cu2ghkgsy~lptufpf z27xIJIWSl3qm*n^Rp!$BOBsjQA=ac2sg*Phqh{7*Yj0W0c!`RN#zBq&NZlLdRTs<{3qLAJjU+D6OD{G9p!#oU{^!NXpn^ZJd$f zXN77AZp@MjNuDH7WD5kcOqLTVQXA9Q#g~jlRlryI6saU6&wvzN%RR!22hS4CZLpLS0 zZc(YofaG&@$OXinSuqLKYsg;&5s{!0Ml3N|v>C9BIAf!z0TEQh2N=Vu5RoWo#g|dm z5~Q%2409GZE&S7AgterrtLXySn2)`$V#qj0W~7!;#caO#XTR|;Zr->Nf14N*P5PHw zpOQiVWEdhU5{zLH6%hbTj4a?aihK!|oIT~-z19ig@^?+JY7a{}IYQ;STDsIbcYN?P z_!I__q z1JnJ|wS>_Wm@`lyNA%0C|K>N}zI*3(2r^;`LqU*1OI1}vP*r8-tTCl!DT#|li-qV6 zD8(Gnl4dZdDaZnj0u&`-5kFBDy^Jy`cxew2CBkCW|KZpF`1gMMH}Bs0(3FgdDgjK& z1mvMM3970l&fg*;qCstwcp;+@6i2O*DFdwqMLo`09e*YYR452g5-1Vo%l>zN@Atp- zr7yq#-n-8G5le!KYBIW2+AGdm(!_YEN(r$NagpZzto$GD0iiWkL6 zOM}*H>iy+$NI)^4&+k8Yc<0WYu5&S`UFb!+INJ$Lui}I?i%XGr1|s?aMfAs!ALB`1 z7*JHsB_FR*5XMrOBs&K$ij$+GN00Yz-@Uh3cHMG5VhQCY$%ZoBDye_wlR~4AW?Dgc zLP@+!nr$X9Jd8Y0gjH+J5H=Mrm)&d*L&~vuSv38sDhjC1PEOu_`|WPE3_&Q*hhQ4G zJB;WN4Ase@Gf)(X(8wE6FsrCxE~7vNON9bi+Nw)e@?jz2{l%z%l$dN`(PW&DudZ1{*BX24vQezqqlxUWSk>#a9Xb9T%-S7Xw zmv4V?OTngT*|zz1rD=-5CTT!vc1ok%L+7JXF94z?qNNb^DUNj$5s3LuhaxvAN)T2| zu?zk?@4oxw(SzCXfp>iwgiI{)A#f3@%7i3Kp#-k!9v)u=h1Q=-ag;ZP^Kr&hu?+*g zNVz{j6}GXjz53F{%|o|%46)po(?*Rg`8EU~xxuw;M`=*4 zTeKFN(Ye`yoVr1_;cExiP8%Px#{Tf9Uiq!xxWB}H5TjUQi3+VhJoQLQ|5rzpYAqw> z3IHlmtTZVJ2tZ0yoJXaOz5zt1Pc4Rv2*3-M`j;;rhW-1t%&$)VCi(3_T1#FlhuF2S zjLuYKf}@M4Gfxr-|*N#kwZk6gebdAA`$z2!CGaxA@jclM}uS)3icuDT?kLOxww7WITAzZ3a zRGLyNewmPSN;}R)k?4E|_QV*X-H8|lRLca^{5OZYNwGEiAzgbRos!v79nQXMI4_&3 z9313^sGfyg(A1Y_&=@Q zp_%KaP?T@wXu0W_Px&waz4H8L|JHx}T$A9XFKE1;j#zspfn94n>_8EN1v&qoWm`SDu!_nMvuH1WeN%cip*~d@J z@j~74BIe0-_tNFE)0|%FB;}?-%pOi{sm$`B#4)z>S-?Y6Tb`e-4V_&O@M-n81S&Vqk7& zWqk(;!H4uxzz5x!P9|-0E{gK7yv88-<EY4fsZ(&$2#x+=@|o0 zqTI7-C%d%!>X>sy0W$-Nq!$#~7Z$2@WHR#~Ds-36H9Y{}yk9I9@4WNQa=Ci+@bOEp zyw)`4M?drDn#P`oB75Q3zQo+L8!zl$``3V)C6Z!@dI(nYqNM_hD0S##GuOhE9!7)3 z9onJj$Jlie@$vD=*Z#;{MeU<4r;i>!GON}0bn|cj z;=jLKeZOtaK{3P{#qAhT({8*Ff8~5%6;)9LO{>`w@;pdWnWyw!%jf#TDIN`YP$WWH{X8G=S+J+Ar*=$NQQ`b?}JXkTk(>doEOAV z`?QX|n5CpHR2>^x(jotoonLmR**;KO9x<4a{BEsbqPy2#Tx?B=X-c}gyHl&4rhL@k zu!`|jlU$h&SapM{LyO9E^dn*3QJb3hThjalpx<$2}NdSEx$>LDClL(w$N?(T)H zCUmWT>RQ9}@oUdVb5T&_9+$46aDBI0&Qx^1SUEpV&WGH6331$H$ZQh}VzUdBF=P~C z&{2{m73G;1wb@^rOr0q+C&yQw+}(QNx@SOq11}Zbsr!(Hy!_-`fA{Xay}c*C@0ZJE z8Nv#ZyOMGADbp2gtdNot=cu*FRW?mka8v)OvKD8e5K$4`+}d5tm)jug+{M|k_KQ{T{CHH+`IH@Eh7b%PMQy4|kT^$zX&~^_m_{8q#yKUq zC6nK*vK;A9(=?Z^UHjXg|NQn({>0s<`!Bup@}s@O5Xu-|(h?o(&GGTc*S_}k`wyQ! zJ)S?jfA8q%uuR?TD-@&|MRW`rr+Fl?ihYylC@PCkt?dONrsh5hMccNUTU)>I@BNaf z-n(=EwX0W}dv`TJGv(|2oW`jm z33)W0z&fm*Wg=7~2g5?9w02gt&NC`eH7O!96o$>#)hnO*kAI1nUsVP0;!D9=Q;0#y zU}P!t-oO6lTi<#2=A-?S*?e()u;*Q0lI;}=Vp1)=j=cO^tNbAV21(ZQ8U>OP6muH| zEpe)HpwZ0n&i3}?<(7?k^_7>H$bIrvVvB zJXw63&6l@t-}%NLeLav>5eY%b5Ea8EDf%AB*3x;DB3P7Mhg2i4sq2yYzi+W>34n;rVW^gNbr48zrqs9;cIeC16Q)t zFrX6G@kh1|X_U-^6dJGhrjYtKga6bl0kI(n1TC|Wf`m{_5iB97s&xJ8-S^&KtU5bs zCz}^VL}kcu#iie=Mbku{9Q$VPCdnfxMN>~z6IUvcMrvym8W9d2B`R0xwovg(iI_nz zG^cALSC*^p=8gCIzUN6R>b-LzxH5G`^nT@f=7$$2X(XsPsnx(rmGuFIUd{W!H5}KPA&G=OD6^DX|sN z5WKns=Jh}N!w@_H#;`G_vDTP0h+4_b3F{PBw`-S{f!8Cd0(c)|x0=lgVUrbE9nV*;ePk68 z0x>aJ!>U2WrzvgB%!Z9=T8o^o3&D#@jAWb_jbpE>q9Q@OcfmUn!w45HT-e#(KA!gk zB@Ga-I-Y8L@m`(JR!S!i!WA5&G=YnrM5sMJBiVy)R%&cmXDI4Z; z=dN(>>l7QT5+|91R7HiETiYUtB@Q8|Dhxmf3T8X8#=G$S-}i~b!vo(hB{&H}rY-Ir z;uR#SAp~Y(rgk!E+X)dvMa0MHz$wBg)8A?EA}ZpYV+w4IVFNNm#GEE{M@y0W2b_E$ zl!@7pg<&RDAtF%-f`~#zA@Mm(5Q2)dZF~KN7jNIbP1I}fz4v88Y{ilgE?(TVwt)m9 zS}Yb&WhQ15bx=hjw^i6064k|W$*V4!2-`GqW2CVkOU20p-|b`noK9nY~E?;4H1!Y=16PJrOTI%Z4RF9 z5#YV^&NYpVp@E2ih=M4b34v%r#NBG?`py_**qFweD21#PRH0x%O&;ikD8ws4iJ4gd zkg5bh&b>231?^-?#IyNa)6Cc+1cpcef$G(3*TGHKt;Bc17l%}_WVPxZ+`ISjmtNl4 z*;&lz-D=r$Z<{u%u8KfI)JHZ@B~`>pv#KO2BEmUHi6m(Hg_Uy*nA3Jz#;xjZhbXZ` z=VjPX6V);4yYViF5NU8-Yr%wRYdqVo>s1ySQg><9t>*LD<;z#Lwl-I*W$1heJ_H}t zors!-KxzrhtU32ZNoY8y8HeGMGIc<6o{F-D3=LfZ84=Z>s=+%pHijk4Habq{oOf!h zVKQ#H41N`YiijwFsbJh=bB81ww~>xMaauPS+t0A(@HI+n9(NJ%~9*N|q5 z;zh-cRMYuUd6yKrl;}kygbUBVV!L^CL3EH`}nKOMuFZ12_hj#9=2XB=OOsT ze0FefK*U?yJJ#BCca1Tfq@Iat8Dx%DCpsY+bwrZJ=Wd>6)rg>CPeWBgT$xhqO^Oy& zg700oS}o_XF^w_SHq(ub*Is*V+A@=h?|r`j!8Vf-OM-VIN@QlU<0p@wY;W&8fBiZe z15ogNaLZ^5RYUMTb;eXx1;k(z^)`vBXb>H6sKzKb=lLm}6AJ#^SkRofjxC5F)H}cG zI_F&!UIJ}xZGY~kKc^Cy)cekL%WgG`kAAxUWOj1AS}jiw4<0|bx3RHt<+ z#n{)9X)av2@cEzl8N(D!7n_#tbi|U_5JBYR?q(L_}hiHTBPR z81+C=kuoDDj%XmCMb#sUQXp?Rs0yeM1S)lgCRsfqV`8*wn#LMyr<>a^z4Y>QI!U&l z4cjDNkf>m;>pSmV*L5JWc0$C-|7G42z{aqN8yaM~^P{tVX4sS^E=1zWID3=rSqyl1V z4G{}8gy4NxE@sRoh?sVxnQoUk=L8Wiy$XXC#$3E~nTc%Es7eSvjU!3)iEM4tw3Doz zfQYe%m}BqH+Qt|Y6*qQ7;*yz|6cY(h7{gRnuSEq?RZ&4^e`Gv^xHYx`l!(3e-D;`6 zn;o6lcEdI;$fyKe4}!gmD$rWnb={-;_h0z}eK((-G;O;vX$jhONzru>-K6hSbX5mE7uB+kC_9vFt3fJIh(S&) zr|2F?w$OlBMM7{yNvFK)MS_a1Rx2Xj-nr0w+1lPY*niS>v)EM_vBdWa1+y`MK%}$T z$%Fg%e&AC-bmhv``Rru1T&ju^i8!cdo+Mv_?4dRDq=LBAddZAla*qK2%4si z@@TA8urSLiM3byAA})mB`mXQ$rnTEU8=G4fHn(<+ZKEWr_mrnc$MzDD5d5;!gTn(6 zeetE2c6N6kJ$%sjU5sjCEEhwF$RH+)%Sr^n5JX8r2x*8YO@SwFI4!^=W-uAUl+;;a z+twJ{w2igRYSpP~*Y`o7!HanB`Va!e{x8wi*3QLCmoHv=ZZh38?Iu=xY)u)rDZO`z zxeZT5P)Ge@K707!{>NVU_zN$-aQlM~o$H~HAjG61N}>{!NQIP&ZbaK8aoG&O(3s|; zxYV7QjWxy?0>z$S2!7yYOi2WB&V>*p?K{lGWUPhjo$HT|juxwaW70MzjvaWa)|{86 z#oi+Xzhu+cWe1d$+tJ>Xq0BChX42&7px0f4;d$!u{vTZJIDX*9Ut9UErCdhb1`LFgeK&=AZ9 zQMbZ;cD%p$h-;L}$I@~9O;8tuK zNM>7Wt$D9lh zws&@$cB0~}ZTi*HhoBlnRr_95T))aDgMxP=DQwXcA4#`deB<>uzxn2y)9ou4ufFKe z>|T9o|K4}J>&qBHHeB9oaL~Y3f@6qP$iY1P!(1} zN__<(Qe)G~G-Ir78e)44I#`IOJ-`CHYL*8EJB5s;?XJ=`xk>F!al!zs&h$4Al@E2NPWHeORqJ70Iln%3wz}a_pR^UL8m^1d;dN zd3V)^e(pMXLWzr1Mukx&R2JU#P_a#Gm_u-?()ay*K6mb7*R8g)hDm-Z$w#{T?b7Ny_jcFU(ujV1Gl$6!{Xf;0uC~v&XbTRxNM-ChSDfbY)c*a5((6ut;mWpE-gBr&y6wB z0-HR$&01c}XL05hgNckNBm_`klXs{jCXOPnlx9IGpdl`_)--Y&L?BAcwrN5TiQR8d zTri|VuO4iyu?@8*>Z$Xb0IladwbbJ|@ng|l%yry4HJW-9k>fvP@E+!w0UFDYhAwNlg} zOg!_YW$FzO2hh5I{44*)pM32*n_FhGvn@gX@fQvOeB(Q}V;8Wl@-s$}f;)glDC%h+ zc^LFhiDG?b@kgTQyTw2HNKMv||em(>6x;9A-R?8uQdqfb!gK ztl3BfhLx`aD|GmysHS?H%`76@OqzyWP+oOXO;Q;s9VWNbv=wMN_Gd$tpV0=SG@O`v z!{vg3=8_3Jg(CNu*HC2FK{3z^DCt1poWe_K*dPg%Lh}ALgP`Ti6yiM7H2INGc7#Zo zL*%5&XcEmp`54lGN2@p?3z!a%)vj9-V?OhX6Yug`#d`YB2n94o84*R!44#7`?i)dQ zG-8aR+aXqE0BU}uZ0_i>lp#u% zI6@MtdcrB?7A|f-U0&@U6pxk6)Zkl-2U>$t8RL`Vrw8}HbaFJ4kex^&yv%W}e03p! zvrflHL=;G=U@E&NAX=?7uTd#_zVi6fD5A-(1>WgAADL0 zR~24DY9~s$oy3_a_zcAKqLkUqS;nY-dNnz7Kv9#Mg(->PxcN{a(J_khcYb{D?LYjT ze%TvU7eIuucc(1iF=q+Qt)4U^%YC+2iKkR?Hlir~TCMI|hX%#Cmt6$TK{1YEk}-ED zvwOjL?+)9I3*1b^b-;39N%0*e@kv!nYC@1)$*9UtW$&v%5;{5|OCqOlL_s3uOyLUb~_Pyq1mGwT!YF}7ZvO@!=6r7OCC=MNNx>n@} zS?+R#Ri>DOhP|-z*_wB#Eh=~_X0KFi^or`+FP9p!TnnsOugp+CZ@i6(j!~3v*%zLV zVszIMwVjV5KJ7FFq^)>$_V~_nnakz|=&<4Bc0e^I->}cYz%ZF zZ_+nv!8(!{fg=EA&pcx0>BN%4FxbHLKoyv-5qU0(+>|tuDS8%)wL9l7*m)>)XqeJI z10=)DG<6P!6S^u9e7dVR;(W4?H9h&+-$j+5k;fcALABhrno(Fe({o;=m`oePXbeCw zEes>9TdvWkGf)hlBaY#Mvr(M&Dk$=WV-J*9)M>@7nk&}WINJyY#zF(ugWznvLBN5+Q40t-1j3halz5DnD8gm&KrZ&Z+rjr%%c^%3Z8y%l@;# zWP&0nX8zI7|JTG6=T(FNrcqW($Debvo}z6mQ-m|2=BMgfi>|-^l;G4@`;$NSpEH3N zL9tpP1ca<5A64ZiJA{%IO43B2ROP2+Tl23vQi62!l*cH)d<6LzxT*?(cAx(XpZyCj zdxaM)dmJrrygYTw5%Kv$`Ik>hC{ptHEEEIDYZCisL6H){01;h${s(^aC$BD-I6T6t zNAKZJYe{mXkv=3OG?Jh}sUsk&f2;h*=T{e>zZIe-+I+Zi= zLl{~fI6{%_!AGDt+YtY$P?S>?0B!Uuy7nB${bG_|sZTpyAEzciYf1X0*t9Cbp#rl0 zTpt~geV(N&6yr>DpbAI`$4~E`9K3bB|CN9AKmNnx6Iu26;`BK}_n#fXi6 zS`=qnG({|6aI$x__tx?L8~^aX|CPgI>74$-2h|O~6^pSTt;D3$#15lasmR4R;#@no zU|I)6rx0Y{NoR@s*Z?Lx1c-N?1o!0L7r*f9zx>WSkAD4MbO{B3v0&96F{%D{fnww_ zPwD)bC~6YVc;)VeR$sjDB)Ege@BI2d_|M*Y>)sc>(G9oym#XqJ;zmVq9R^f-Pqh+O zk{jJcw78U^oj2U58sapDgTp=V!;k&QkK+6)KO+=5>vA57v3iYZAEPMOWb2pG5K_1L zGb4LZiPOgR_8xVs#ZUdj=Wx2KSYxQ(vtJ<~ZifLfJkZTK&6{GAQ$Der8clp%Xv4&X zL}_$rsPeL^l`b|7fl-L7BcgLU$xQr4;k2NV?Vk~>x)rnpN~8Bkeo%o;_c zyfBG}Osy(Xm7n-b9kZprArpgI0NXpywbQkdGF6Lbr#+H2%hXto&ql6~Gm^nPuw0cO zIlxL|CwuXp6GK6vrOFS3L7e?K5ug$RMGc5^ zVmV0}fV5Xrje-h_%B8QN7|@JRj699zGmC?%mC`%6m>r4hMl5MfTAYyg6)F-*lt!!v zo<~Va6GnkNgjS)_@u*Fa%E8}_7+G{D%i=(A`9uR(5(&L?@4fr(-Me>u(1Ic;LY$kK z9eJdRt7a)6s*gH;C!%qcpVHVBioxj*x)Yx<_?R^YoJQe;+<)}s-rc+VdwV065aM=8 zS(jB6rOFQxRg<9dvm$`CDnAitT+~FP?DblgK1)7;f+pu8srs=fdJ#DhC1BC@U-`;c zzwpn01UlFM!B@WW z@ZqDbb3wT*ABB!1w4sqRirSgPa8!Z1b`&z7R1vHE)F`q~8pAysMLDm8KDoD$Pz06z zrw4ma9ur$Osz~Oy?Cls#&ZP{uFZ0iIROKg(!@cwKdtmd%C=D+@Pq#^3gKWE$dt=48@f{s9ivb=PF6h z69?)h2O(Ga8D52DnQ;R&tz*d1s~$zaxE)@+aPWBkA%>|PxylcXT4ycEklIoQ7*wd! zLV4`OmLx+C2m~WvS1f=Q@~Ftq+1dQ@b(?&6(LA|(^ni!09fyJ5s$ZS2ifvxJNN&qf za8$MM@P_2|&ZTJ}ZY(*Oh-axyJZEd?g$)V~krNqPJT3E;3uQ`is${ zz@tl(0O)%ARe*+`&6y~!z4+Om`w#zzoh=@eYp7q6|Kuew#Tl=OYo;$Xc6As7r1J&i zjGu?iZq|;Mh^6ivWOT%4_v$BK{lrDaFlt>Mvrh8ZF`Pn?Jz+s{)@{zkGzPMU;d~ST zU~Kc;$A0XS-*?F{of>n4PVZV(8LGU)&>yB8b>;sGZIn~AIdI51R4ND4 z??a*>-z?ua9w&g9xM|w!FTK2bp_=y;>8uDnjl%F4M@|_Jtyn)e53Gz(&>1L(yXCZV z#xuHcg#rK>Hj|CrD=+-W#pf<=Uf3A1B$GL+^0USRjNi-sYk}6$(~Df>xOAONR2ORs zi-t(A$XLyzq4jOk{>}gB*Dhb$n6v}`C_k&*gbF8fS8*&pR4qnnDQP zdj&+XIi1?ZCT5;tfr^Jq5t4V^@xh&=y>I{2U->%#Zrr>z#G0k6G_c{jLJ?2OwTcFH zr%D&jLLt3#hFK)EZQHbM83a&_unZA+t`p$;#mV8r+wXnxXMW+oMLo4n(*qFa7e&Kb zQspB)rEo9LnyrMYPWle|Ig*f3%-Z+v<~QGNA3r?$<8N&IjlUT-Hh=ij-)D_UEn0fL zRj{nXL`fnx8{5wr`;VOxi6V$}UH`3bzqeQ{R)_m9 zz5J3|`{_^pE8H|gqAp#O;>prsjzOz!+R4V|^_Tx#Ja<&T1bNUfhZHfQq$Es5%HsXf zkd9U{V&O+tl8R5`E(idK;_&GB-M{xg;hnesaAUK3`1o^w{cpVT^2c^B?Bvk}DltuI zaa8*j5?kAB+GexdaFC=-P8viyjUw|_hxz{%irVfx6NQQ%&*s1N+rM+~_6K-;|MJH4 z$N$#PzxL{@wrQvmq#D5RH#MXoZl+t;M=a575CRk+F3MjCT_HNfgJ(HKWJ9rbu>PXC z=kLAmdUv<$=ERSlyVf5Z+`9Q*{}+E8*eTDgV`t>xJjT-Ki?1a=^d+y?7GC|cpl`8pB zLs=}A`}_L{o%$|>G?5@kTAI#rOAi{_P?I)P%*Y0f^2)wbQC<$WRL?~L7-Oc>iJ9z7 zE>3zfX0rMG)#r(-nY}~jXgCrt@4N2gEvRKl(Pyn|3S7`{q$#7HlN>WgageQkk*StJD5LwaBnd?7D>IVRON@jvBnZ56tQ(VPyAKRd`UW-eM!mVWlmIkGL@3n>q8?= z(@ZAqzx(q)L&h8&9DnTM4nKUzH0sBL{ z#MH0?5F(1p0Eyy`35?jhqKRQitOO<6QXN)XNgQ_>1gwNHl}aVq9l-+29vci(;Q$-#25>X(aI2z_xJQv3r^U7Lg`l`e`^WlEz{ zV#p}dHmF$%T?&e90W{m~_@tffoz2ZH054p>Mu0wl6?ATupO|X7edpfoJNG{P;MQu@ zJMTl>8=z)M%*+!~u#)QI#FkgYd@$$j76?dtXJ)5TR3_n!OO(qp(-M%5f|q>6SXGU? zFDtKM0ZqXdDGpQ~^Yg zA?6>`KkD%eiwCnBP_hUzW=Z<2073{y$Fn-dxU4 zf(Q{4NpL>+@odKj_wPIJMPxFW5+@Z%TcwG_=^ZB4k3ifP6xMK_yux{_9tF}`^XxH6 zp@u2eY>81#-1;I_z)>Y*q9q@;7iW$&8RC{?szRpeo!i;Du$a$hCkNf~#5t!56NJf> zHvRC{&A88W2tJj!A!?e2QsE9Zsk};VVj^Qw&6QLMh!D$y7-LfTBIaCfIr3GJ=SC4x zDVsyg!FvHhkZ4UL$Lkt*Z1=%f+pSjZWP0cJ2e&`Cad5ETJI`#I#txphuw?)7L+ASC zV&3;18(T^l#69N;YK%43wrv~R7phHT4Vzewr)^tiw$?_O4j%lT;D2*vurU){D zNg<{xREdpZ*m+(>6iUQi(DyC`Pmm;)s;pjBCG+IL?OO^G!26&Qh{-S;X2e2Us%+R8 z8!Pq1zMwIN;?_`%#x}+_CRQ7;c_LWeuFn|eVfJ&Hd8l!BnAkrO4H6Rpnq0a_>OM)3 z5X<^OiMr+ThK`LTQVBhR6JiikF2jWu0OxG zf7p2SPQh)x8G(R zTaTFH(lj>NCaY@P#U)N3HkK$BDO1%DLfqs%nn#fki)AUP0?d5z(xv-%ZmD>H$;3?B zc%R^YwbrA^v zk@En65W>aFSK7udjt|M$AWG0Ur6`YM5r~O7ZkR8i-nk&!v<+;dL>g4ok@q07#uBy0 za4cE}WoDL~5{AaIEShF5l)N5sJKa!VesZMhQ=*cfK1B1TV5MejXNQ?%Ka#mM(N5MZ zna$_#zVq!r|KmUL@++@AeDH9&mAOwp`Q#e8=1$?yOE>E`t0 zGl8Py43cgpJtEOxY z8#9@1w3BJv3XN4jeEr(ht*y=D<72Q5zLy|nzoMK&74fb=c>44^Z@;ySSl>3uN`GIx0Ch&qLo?#H9Kc1;BgnVnG8ynb_FI8ne5z zxxI5?vbh5`8a#;EOv>Han7;4t+`hxa7cX6!OeRfZiB!eMh$Aiyh-J?tqf-@*aaPk> z+cegu7}}V$%p`V?qd!Sp(A-Zf-H(DS8iKedwC}sF?>g@s5lyC(-QC^Ijmh_Y^82^9 zwuzz*4SSpy(&b#Yx3||!rk9?(I+;uwYXH&U2_R;}l*@x7@#4tcxN*Bkp z2U%+=d8pXUl&#HaaGffwZ6tW_R#``4r7%SC>ATf*GM!8&zF#t+5ET!$0IMoQa;jEy z@*>&*0x`3u$T@XHhT*KzV=oC7E!Hb`n5nEH$wElnnkg}h1Y%YRt8VrD_3Im(TV3yB z{Ri+k;zHUDo0CB!uWLjVQj5vZ*6k?fQ` zq9T%cv^cU%NfoS?n3#>7*rxR%R?yI{>%_a`|EGguKIQ|CAQXD5NjA4uITP#yQzf5e0Jy7d!PEDAG!AY$NFxyS}uZ! z1Q*0Wg^g8>wL_sG5r`;LfMHcpm?0p95+Jcu8&h1EMI|~=RbaBQ)wYuu>csi3u3L$8 z-uJOGn)e}u5LB(@wrMZ!?iyp-#)x0_eJ7$QF)OTodb2I<$bXP0Vf>W1}#J5ReM@igR5E z-aFU3o1wrV7mGU|-u%R?pS=F@Pu#ur z9=wOFNtFKY4IR zWA_QdY-3QIj%bKpP0~vDysvk4f_J%?rSDe@)slv%98afE4l1mo z#G-ol{-fXh(pS6X+zYvG;k`@tH=C4Vj)_WjVOcH~s&eJIt5^8i;r7fYtUPO7)CXO;9SqEu4~)L_Js?p z#XNCQW3Dp=MXS}SBWjvI1hX-gn3Ko{3EmNrw+#_#2nxxI zYb9<-o=3c?8v6zjtT9f+c{x6r9UUKwYS(oDxqj*Ug(zfg2#Y|HOCQC`PAUp$@ZJZ{ zVwt&VC%~~F3&m)V6eV^iHZjyvQL?26Ptd%l8Vzz?v`eeKU)h*wG?EE2rkzYAco8Kw z;#C#ImJRYs{6tC2>Z^*XryMnj1cnk)Y2H-6kmB%T<_Q6+-iJJ+ik3_xj%Jqh9|X)e%miGCX9r P00000NkvXXu0mjfoz{^U diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-rgba.png index 104e15d3abf2d570cc31c344e90f0bc9531fc8db..d609ef2f527272fa51aebd1f61fe12080656ae8f 100644 GIT binary patch literal 19937 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 literal 20326 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 JPDHLkV1f~oKwX#&rmW2 zS&hLXE{L|q6GC&SVd&JenB54?Je&e!Z*)B2N)FbeC{NSa_8116Zz56~zA*PbuU@P* zc87HlvimB*xvwx7Hk?FuK8Z7YHLE&%RL6>ti7`7zM^s28it!}`H(Q3B0u>L%<3ry1 zKY}*eeOrg*Lb&ItK^4pi?V24}#fh6cH(dxj?f(RaGc@?Dt3#+M?*(&WXQ!`Rn`cx! z+yMDm8x!E+3DfHc2=TaR7y?8E^^?AM?5jremD1ZrwQ`PepN5aX7-Zi_r>)Tk~Q!%-Cx^B2|xGrNqe5`be%w!PO$L`*#1-Uj*|gU1!o^ zEFJoy*P(pd;$Eg{hl^(w zxxwMa>#>#j!4bhV299-MDDynLjZVAmlZN|a_IjBMM?}b|4r(@5504SJAGt6Tdl~oI zOE;N6m1U5lFBwy7QiPY&(VCipWt#0Wxd`rv+W|tIt!$I{mR}duY>+gPeLg&uh{}=E z-js|P%;7GEvkC4j@M=WztC%pi-~>Eap*tIkfh;X9UB5x>RTb0>&=@Uhj_;#t{=w>o>X8@;PK^6q@JlaG^$ zHje!KklR2J`j)u@=*eZ?f$U4?$_L&o!3OWQOp0`uW+K)HE1d#;3>i7FIDt1V>&m8v zNQa2RoVd40TQJIUM8%_|0XH*$a%WXyIwM;qF)-NtMqmE%`y!EOSa1y$PhEU`hUAv) z51@VZ@XCppC_AcTRVWy#&Br@1a0;^XBiQ(s$?+y#7_3eazFy*AH>1WLoEI!C{2hw6 zaYop%7jI^ZH!$lRRC#h%b*Tn?onD&A2fCp&OI1pCduW0KXr&Udo)RB$ae!LpJ`6)A z@QL7_ddYNit%{k%+_+d7GA@=u)GGkCr$_BeYqZolOU%80R!ObCE|OL+2{{-qS<2G% zc3^yzh(BU}hLm!*prEU0Cf#a3r%F%ByxR>E1NyL?(do}9dM?mm6}H8C<7gG$jq>EA z^tS=kLj-Pk@_OAR!m5od`PX#+p|w*$A)?)`au~EiTN;5=GeC`0Dyt&z>I5U=+vh?B%6P43l|4XTdG(R?ykH@4s&^_b zjc4_|mWUdINL06I-K(jXD0<&lzFyvzegi3x^o%*7v0dC4 z?B&<2s~)(91;M}lPnXSqbjAMAh2Q%_w$;|HC*AjZmEb7L%6~SG*Dm8UsOOd;VA$I@ KT9;dqWB&yw;q5~J delta 1530 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 literal 1622 zcmY+Fdpy%?9LIm;zC^t=w~cbirL8u(mXbmn5=t0nt;1YGC(1$RB>lutS;nC{VmV2- zxnIH^sOFN|Toz|LI_?!)#A>h3D>oXf@=+EcF+wZR~JWtSbtT*Yy| zy*xGXpL~b$V1JK8^Yd_CCD`LIlhZ}4UsIg7HIN_bD>7DVpSgnO*?9DruZDK;>L`NIuKo3L)biPx3xGw2R#NxZ@q2XgNCV!!~pj;Z4SP6Z@JF~YKuFab;rkWVL zC$2!fLvY(OrP2=a&;RWW$u~9AdbJNG4G<6tB}SK#9(JGlm7V3}E~y0=k4K3z*g_H7 zmA&9RGrZ%J)4i-5L@_}X`;o<`Dp9F*CaJbif!6Gtc#OC=X|bq=31)YlP3R&`;{`80 z_EF>@(J78kIttR>UafLFU%#I+D|5S2uvOU*k$LJ!Ta~#Q3ezMc1q38l)9fM9FMKn7 z?rOE8(9L?ydz!=82au=YBpGgDrc6(;xd|l(_z?D#`{S}iX<@wXt%|G7msMg%j(4E` zB>|Dy>Fx*LcKHiT6LFNpt&Z*O@%UpwkMs z6h;`=IXoG4YUxwfg-}>}HOC%C=m*)&SwW0 z2X_`9EP>Ls(aJ+v#Be|4E4!ZAD>?dx)M@re{XHLgLK^kz7p<P0dTQM;^>A-)|PTT?`iL10^J^f@j@SrFV}D_ z1|}(z^KRnfqXx&aviINjd&3OVslhAMjg8?~+Sb?CT{k8Q$(`k9mi@)S10X;7#rWtm zuB@qbpbH5@DdNh;Xew+Q5Gdrvef4Qkkd|iDTrFd2Lg$CeaI{@AT^rXXW04J=HDhD_ z3=@(kWGzL|^Wj=GV`Gr=%raUK>F=L694uvB$G_U*4Oz?kT!Sw#M@0*EFSexWMa+-M zvrx%C96r1$>g(#4@Qz3m_0mFy{kHTGNW{U1G#X7miHX|E>RPfK3@=zO3G$1D^0Ymn zwD8Cq^K1DZ!sy3ynIK2iT~WeC$sqT4j=Oyz%>vJSUbw5szBX!kVefpgKn;R6I=V#Dot;WXfe)mt z7ZMw*qzn$x|BI9|MV8Z z!%lpBdHLS#+k1Zbl7kqj*!lotLmt!7T~<^@I<3Xs0clSRK4dIjMh4VeWl%0w{7D=p zpa5sOTLw|JnoK_Hh4Rwn&y5CEup1_Iut{)qb)0KRJz@vIX0yo}hnao6hQ(a0u=_Ac zX1kN58&%qu_B!uHn6J{)&hqozv-RE|v zQ#GIlM9h>)K6bC5RsM%;!l|4igO08DWglncz8{ zP5qA&Hi`e~PZBBpMwI+c6uEDTPT%*JW}fJV{@4(c+47aH?pS9h{4W5kt&>guF#`Ax DxnCH3 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png old mode 100755 new mode 100644 index cf24e0114464a54b2bd53472d764537136dd31f1..b63ca3cf01d9c6f1c27d48643d2202d01ac2cc2a GIT binary patch delta 1557 zcmY+DcT|&C7{lp1Lc~9tezaXN79D_gn5K>u1^qCIM zeKiwWr=lL3VWGs>SiM$2$y$X>CF4oAlC*HrE4(dke2}>@7qF6Ac5f8*D_lc8`z(Yv zL*r5*NuMXSzYlAEW8F;#L!DzhtCf31+R_#^o8PC@Icp`;2_8!*Crk)sVi7CENMC{g z^{jx&pepDf`q`e0gB9e;p&XHvJ^^AVB~a}#RxI(;40k|$+_NYUuB~c+Ar(?2BrE$Q zsAVYSs@ilS;6`8@pgQP&>qq~{luj4p5kakDVs$^iI1ZLt~?NJ zooxs-!DE)=YbG8x&&mxjfnl+zjC+Lk(?YTms)8$V!efQK^LBVqEY-;X(XAAsQ;ccK zo(CfVU^?;7P$o7-r1y|YlMWpAag9}`cUiL8U@BmPgx^`~RAPSjK)WNLJyvNjm*MCI zpOP9FE;GKI8wvG-|E<~y?(f0t3ox*M^4q3X)$A|1HfyxPdl*+;D^X9QSax+2(H5`l zF17ggIGnE-*kwo=FK) zt8h7GU%;s_sxaiuNcYLW8uJxToKV2EO#(%8amT|lp>#ALY~|lj;ZoZ#Pj9Us?SZli zXSXUDQ}6~^=aFC3H{sd7nHu`-Zlwz`8Ce279zJVaI!g#j#j}s`tE!->;_SS{enmie zw^HDtMICvhBOpPNj`hbszcr!`>#q~;V*<4bBt{n*cSaYpenlOaDOXwK?y9#AVNFQY z^cBdHn@cX+Lqs_}pZGge2#WD^?xD{@sE#5S`Q<%3VoxQ)#TY!i;{_+Fay#i$JlmFm z#Yar?>y(Y=YTgLgI1y^=o=S;mq+tr}a9-og{nHKwHX6s$ zsyrORhBx3oInYdR&g##i$h}I}b`dFI2q9VyLPKvw!({S#++}aBKdOvfX;G)Nzs^x7 zoN^@-HixGRqBp75{01+t5AV!9U(d%W*^Zq;tf;!-C~QANL{5wLO^cT`{OA(r`C$No z`u4(s>xLz=wv;HOZw|B<4P@KhH@EB+uPvIB7cagTnezf$w$y2m#wJw>#>>>yR3Vq~ z1rf1*h4s=9RxHRwAm&_&Q?`MvyZ$txOE8lFDL1!7mxO_1BXh2sD3WL_j_0p`X;K6k zlE?M4qR312dMr8Y8?Y-L)SC{c%b!s^0x7yWp@1uA=wa^0!-DGO*QUEyW4aT{12J5y zq427A7xjt4eQRrL)O1OM&AqwU8y+}{UIE83gOl9uv&lR9zzANRK(++K8+5X+oke)N zhBn0glpgyCT8n;dL|*D_@906M6K|KSjQRMRX$w;{hFp**wW=4=72k3$9J#?&PH*GA zvBoC7Qr5AY&nre^(Swqvg%(hRPL^QTaUU6MrVVd8@x_EcnSy@N?!ymY%r3|+Wj+A* z4N#~Hg@e)ur@RaQ;l4unYASCzH(#O$T{GKwZZ-#HuLHGF8eg5?AXjOM$t{lEQhKx2V_O@ZqJ!LQFtZR6$P%sxkT dP_3oPIhN>zO*P!u*&2Yq>}_3a8m;|P{sm#L_d);w literal 1622 zcmY+Fdpy%?9LIm;zC^t=w~cbirL8u(mXbmn5=t0nt;1YGC(1$RB>lutS;nC{VmV2- zxnIH^sOFN|Toz|LI_?!)#A>h3D>oXf@=+EcF+wZR~JWtSbtT*Yy| zy*xGXpL~b$V1JK8^Yd_CCD`LIlhZ}4UsIg7HIN_bD>7DVpSgnO*?9DruZDK;>L`NIuKo3L)biPx3xGw2R#NxZ@q2XgNCV!!~pj;Z4SP6Z@JF~YKuFab;rkWVL zC$2!fLvY(OrP2=a&;RWW$u~9AdbJNG4G<6tB}SK#9(JGlm7V3}E~y0=k4K3z*g_H7 zmA&9RGrZ%J)4i-5L@_}X`;o<`Dp9F*CaJbif!6Gtc#OC=X|bq=31)YlP3R&`;{`80 z_EF>@(J78kIttR>UafLFU%#I+D|5S2uvOU*k$LJ!Ta~#Q3ezMc1q38l)9fM9FMKn7 z?rOE8(9L?ydz!=82au=YBpGgDrc6(;xd|l(_z?D#`{S}iX<@wXt%|G7msMg%j(4E` zB>|Dy>Fx*LcKHiT6LFNpt&Z*O@%UpwkMs z6h;`=IXoG4YUxwfg-}>}HOC%C=m*)&SwW0 z2X_`9EP>Ls(aJ+v#Be|4E4!ZAD>?dx)M@re{XHLgLK^kz7p<P0dTQM;^>A-)|PTT?`iL10^J^f@j@SrFV}D_ z1|}(z^KRnfqXx&aviINjd&3OVslhAMjg8?~+Sb?CT{k8Q$(`k9mi@)S10X;7#rWtm zuB@qbpbH5@DdNh;Xew+Q5Gdrvef4Qkkd|iDTrFd2Lg$CeaI{@AT^rXXW04J=HDhD_ z3=@(kWGzL|^Wj=GV`Gr=%raUK>F=L694uvB$G_U*4Oz?kT!Sw#M@0*EFSexWMa+-M zvrx%C96r1$>g(#4@Qz3m_0mFy{kHTGNW{U1G#X7miHX|E>RPfK3@=zO3G$1D^0Ymn zwD8Cq^K1DZ!sy3ynIK2iT~WeC$sqT4j=Oyz%>vJSUbw5szBX!kVefpgKn;R6I=V#Dot;WXfe)mt z7ZMw*qzn$x|BI9|MV8Z z!%lpBdHLS#+k1Zbl7kqj*!lotLmt!7T~<^@I<3Xs0clSRK4dIjMh4VeWl%0w{7D=p zpa5sOTLw|JnoK_Hh4Rwn&y5CEup1_Iut{)qb)0KRJz@vIX0yo}hnao6hQ(a0u=_Ac zX1kN58&%qu_B!uHn6J{)&hqozv-RE|v zQ#GIlM9h>)K6bC5RsM%;!l|4igO08DWglncz8{ zP5qA&Hi`e~PZBBpMwI+c6uEDTPT%*JW}fJV{@4(c+47aH?pS9h{4W5kt&>guF#`Ax DxnCH3 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/newton-newton_renderer-depth.png index dd932819cb01d04f7a9c588f5d3e7a51a6d51b31..cb4c89384aeccae1ea4b6fc5ec211b25237026d2 100644 GIT binary patch delta 1044 zcmey&yMkkaay|2VPZ!6KiaBrR`u44I5NYi`Hvh;>P_3dmmYNUg*ubRFfzseqBL!D5?js+U>lj=>0N#5dc(C5fU}& zx#fYFg8_+(zQGOOKTBxYC3IA-4!F5v!$Q?Z0r{K}*NqsaJy>{KXpd%6NHo*S_P|_D ztt~DxQM!(Aqukc3O00Gc6OGbkYy-O?Iq+Ywh-;w8`s=G-Jg)svz5CYvijz;X_qAgoLx=tJS^BC7NUPTG&}$0Q^xm~s<&<}m)>+=`uO9GcXZ!Rme5)i zS!6Xg=IGY!w=3>_K2;=o_wVX=vCWGX-Mat&`?a8$qr4iwv}3q#_s>6{{`vo}Lji$j zC+VDKdd)ZS`fXkt{=)%*f2+3NE|oF4d$nY5oOFNA_UP9WW$V54rh8Y`CazeK=J_}G zN%+$Kb(jC=Rz}OUadoTaE_=7LG-m3LEv!7EQ&oXJ_Bo-GsglR-rn&n)H+N)c%jpa zw|gC1{H>K9M9S@7sMOcEen(9~tjNPy4d2Td_se?LA38m$Q2Nk@i--PmZ&`7nzEIz= zPBdx7gS{>?!uPzFDLfGtv@BWuKu7R6$L5qZd~)frr(0!2|4wt+o&L=(up`PZh{)t}nXQWep3P1TL`$*F9^|w6|KI`Ur>uGC4oQzc%pFJqSzy&hu4o#GcOB!Xg)? z#D|P(r)k7~$vrToUfy@b78YfBpeI>-YGims(~l3b+zh>*wZhzvLu&w!0k;q)o&r{^HXqnlcv4cv(ST6=5r>V;_u*E z_NRv-xK3-vCt=QIe+-_c3(ffS`L)Mepg^#qJ8OM#-7(2VO?zQJqiRN=ZlGV(fPPT` zN*gQs{N%JML!~pLKuD(~uMqiz%# delta 1764 zcmVJ5TE{6vv&km97LrDng(x-Prgj3{3DL8DQX} zz=U{+1r=4WAZTez?%-mKUvVCe&vDOhxHk<>;(!0~D{*}oBM5>Z2!bF8f*=S2Hd)ZK z_xpXuSWy&=u`J6NW6R~zmblmJ)xOYbwW@tA1h{stq95)u{eOO+o53lHBFi#v5@T$+ zT=MPZa@oWT?C9D9TB5^USzq|an~n9uCcINi2#)(MjBJTqvn7vbeGt}?EXy|POSe&} zat>Zf9IkAFTQyqBJVZV?qQA?V^eoFn+rA;x>$!B)mdIvstCH_%)fO0`d6&nJr6^;- z-G3{z#dnU_*9#oX5@KpEL&tr5K3L)C224i=zpS$t5Za$#xX=AOFF{n!D6xC8*+UJ z#S*!r47rC^qZcl}yBiD!rFB|NON`?cz8|tHl~4-DVt=s^?T08L3NbAaFBWm(Ur>z0 zVciMkZ1}zr2$Z_gWaJpx61)|Z&6Va8Gw_W|;7mr5bF%uO`Dv-*YD9?pV{J)M6h%>d ze}5Nch{R%B`ImV=QBcNB%D>{3SEjMOy=^=pE)_EW{{0gtTw8K>c4o|wG7-y|*V3%Z zQ|o-R>3<&;*(WC_mzS6N8E~m!fBh4zExEh9vt>@tt0VU{UZ*=mI^-F_s6sz*{rT|l z(3q82GZ}0O<^2y24`Q3w=@?arJ@@zbelPY=cnyYh*UWEQ%ai9@C>cYv5$U+N-AcvnQ?z>mNcZ545XpL@FY08FbD3DhYVyD zZhvZ)l30%-+@zrbO2ws6FF$FhsQC&j*8MdY3~UoSY5=;FFkrV>?aU;gB5!gqG}2Jv zTXDrz+t!a9QK$ggyer7r6Tic7Cj*F*J)01GlZKKf;G`kjJ&Wt7*4?Y{vZUP#q2Af# zufS1vwSL`p&sO1m3GiB&`Qna zJ&pS)vZQNDT_j7oK-l0@s;GHyivM~lrNbs_OFG4W7>nMLUauE%nc5JSMr=u3Gk-V% z7wo`AbSHv>uarRZqpbyo1eh#=P*d4x(G?sO7g#4`+z|`Jo2$~U7j#Qdm|;XXB(GnI zd(sf(YN`_QVu|t^T&vYeY{(@H;^@7G6L3}+bf7QyYFye9mL;`|1qw?9?scjGS+dCi zVP#^rCGyRZMzhfhaY~6^t2UfmLVu+sCMkXUMmido=y8eak-Q(Qn~FP1^6u~XAwcAw#Cc4w7WJ%bz#PLP@cd_(Gt!a19!dl|Kd3{PoO1Hl!ARaq9 z8k-FyvL(jf<|gKc=c6q_{BeoWKTgb#8U!yT{*j}|3{Lz~!ZE(^DNURtU4OJCr3HmY ztL0|kjgDobs-oW)!bRZtIHS?XaQVLvS0~Nsn65Xll*Z!9*aHW@z|JAj?j??N^iGB;(y_A$ipvDl%G!V z(V2z{tWkdS5I+o|3T6^QQGPnYUj;?^S<|r!2gsuQ*dpV!l85GJ@KnK^LZGaG+DZn} zP=PGUk3V=-P?Vp5)KdYa;!>!WpEOh;i}KShFjW9uZqt^Te!uU2Nrys(8(MA?92DiJ zJ7g7(;tsTVSCF%pOkI>88&D<<5dwWzcGrx`aW<9!0000);C%Jo>7DJu6cBDOhX%3|(`$|Xq=wa$5% zTW*tvI4H7kT*7jhvY1OXb$-HA=a28@`F`Hd=l#6z^FGfv&eqy|s|-vA1Ojcfz~k(J zGj?@JZvysAMGr0rB&lM7Gj<5iexGB-gcEkh4$}f(hurr(Zt8U{iKBK~1@=q8rcpUO zE;C<)1a>qgRR-^!lxqnjPxao2;t-5DHgzyXpT*H;I|~2)TmHO4Kt5bWZ&|p@jerc9eh3{G zS)^}1FG)0h*wc-CaTiKb?ZZU38}0Z02a{%sdUM=fMOzDoMsU|qq$)))O&-mY2Zb0e z7|EhL5hpO+jr#To?Jk(1(f%tb)V!-Vy*P)`S|cY7YBpVNm~YVSqz^|6`kBuTP?e_&cKx5!CEz1@nXJKj8#juI?MOz8Ww@ol!?f0x8Eo`RL;&jh-I0FkM&1Qn$ zL|&+jddLcGD0b3HI^Q@}$QXgQUWtKjIp$%|QagM}2}?P$u)CR`2l;{rk!r%x3(_at zhR@0)s>ts0h`tIp<<%!9k=VQP8-HKlc@p^#?>4@iZ#(1X+* zpVVDQ@PP@kPZ+d~wiC6I)Y=aD`SaMWy!*KxL7u4a)3y?zE8F|a9~WEtaig3{p(Kxv zoTGBMiR`5gViq+Ug6*OEsZ|7io5|)8H^%Q|S7BW^l z-p334DmoZh$)rUUDDf2lgN9@Yqox&CG1l6Ei>7qZKOT?}inH`2LK2=7g0!JEsuFpx zzYh9x;br)Uks~gDe(JLMknWfyQq-son$NO1<}`$3{zZ~qECDx=wHLr`Ypshg{C))* zQurb2>&fNMS2@WOtePxp#`9b35$aBH`pWcp-|x))mU-Pe-mh|Oe2wL{fS9ls2fqb5 zMx0$b<;?PO&h>E4xW#5S4SPDV%svx>d#>-AU-6>ST>RKkos7n%p-k^o!?b&&waI9% zLXIHerR77`*izZ0WMI-vq&u*F@5fo8(i~c3CY^>{HhZO$$nK>L>xA0vMspj4u3%tn z5k3Ru{*T#*xkc%Rxdpl2JE+LzKQ(*?diyU02382F%l)6S#+KoZfF$7%EOELTwnfr^ z!1r;qce|yt&_&&*{&O9>w~ECQ6vOlq`IVU>Cm|DTsh?%Ha>cG~?Ee+A0un)t}v_RaFAf?)@iJpp~ zFWX1MI^@mV6YMOd0yM*BD%=*AUYEar_s%odW_Gbj`rMYMvj-Mkv|Q8)gx8_|k5TdY z$hO491*G;&fCzYJ;`Ym*OF&=n_vceiX9yG(6=yVxL@wm`@%ACqzHsIK@^0b;g$49Y zNR{W*6UqH25?a$=p<}CA^ZJ_cCN^(Fj6HOb;Jy1vXHtJd9UD){P!7ho$zcol4Ct#l z(``PV_A<)ET_(iHmc9Rn;XmLM95K=xfjWjuU1OU8>{VFy2iyB6HfkN-u?DA@{t!{0Ah6*Ycf%Dw~H6wN<$fG-1NVP=hEnYhva1IK|KcmMzZ literal 5780 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 pBL``2%})2#l&2WcVQ!bc1CFRP%EZEL1i* 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 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-albedo.png old mode 100755 new mode 100644 index e02f546f8ed2033ffd9a4648cd5a460f3e5e2f64..7af151ef02c217346aa65b03c7afc2d21bc63c6e GIT binary patch delta 772 zcmeBTo5MCirT(#}i(^Q|oVR!P`n`6LXnDvO7#I-DUdXtN?~cuvW&H(nXEpITZWK_R zpi`8z$#`-l*M*4|m+Gp{-%__vY3x{Gt$p9_*1DuU@2Vd6pZ7kH+Vxq@yR%DW;bfH( zmC1j0uyo%tZA}r%_jGdC@BALh-R1d4m2)mwdQywuqzf18y)WrUuTS8bs`+KojmbWj z^ku)DTBoTpS!7*e%Vd$iLhGk*zP8%p$}f>mo_|z0Wq+!e8~4fRLS4iSUM?D;(<}?6TOeqR7y1B^ecDlmHl}6{rmsh1i0$sJ57@9S6D%vM@r=UN60-YJvj9O`9>@>Eh{(Abx`;)JGsp^c}Ly5|cI zwU2iC&PJWvRe+`*ssFu-^(fR#Pa!L?iWjPiKU?~kpH7iio#YwAKN%h!mQU6H#ZI~~ z(f`mlLG#}yHSMo#nfxPH&_jLW8^)I^>n64IWeHthEmku5$&~pPavSfzh@Bo>_s9b% zULe#g?mZ#6P9;Nq;~kA#o;q9oCk5951!~oRO3pl;GN1Fw6#0fXGatpf-UAK qf@2ye>E-G7(2Z%Wil87vgFnm5i?`N1zPpwI2s~Z=T-G@yGywo@uzLFd delta 754 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 R#{d8T002ovPDHLkV1i5{zTE%- diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-rgba.png old mode 100755 new mode 100644 index dfe59520b4081c02a630da6620261de132696cd8..a9e9954271005f999e462b4af82823d5a8a66013 GIT binary patch literal 19961 zcmV*EKx@B=P)#nY;m8|RC&#fcLUz(>bN$4AFU$Fm+3zkKtpJ4{3%0t^v|2*wZ$ z$!&%JV@x_nL?95D00V+C`8jI{1c1XI5bu|RSIx&a^98S;4OBtlV2(=+qmIBGA=OP`Ekn(q~TY*kGV%fv{_3hq!eDLml?C*O3 zKxF~%R2hS3D54Q@h%~I>Ak2`S4UA5_83$coql6=Va`@s^<=1d1eySg|erk9ZGvm>n z-^bzJF;*=X_yd|TsCZtqL8bv9`JGe&IB+?o#~A=I!{?jQ^&v47BuqGi;WWL<&WUE%1#TUZ{oAV%5NP>d1P=cY=V-0DHSiL?-lX5m~FFbKpfsx|0Q zd8s^?@g*2yum^g-&aoxYnXsWmZ({v z6mck-XCqGDtOqA5h79sF95_Moea1nZm>u82%{Si!GsD)vYKb+Intcq_Hc@jJ z8aA4)kn+lz6$4SSGZC=-xVFQJps0Q$0i=8pRt-g({!&V#Hj?id^vI}f+v2tV=fA_v z>ql5EgY7qfb0vWKJ+arMyVYmm5V%6&fP_!!z7ta5bZnR7z4yV+!-L>KAb5a*UUgSvnTZi7zB3CC>jqUgu%ahE#IdXa4)XOZ92C~y zBMw8MW(NAs;o#wQxZYz_5#Sj#0%|8(BOZmu(50~Y#}}_v6^qX@!~u&c64_HP**i*9 z&`2e`>XzK=DGA#oBPB4!2~B8KVLI(m8L5K-P+0=kfU7Kk{?xLe=qtUJ@~v`9q(4`F z6Aly^gw}D0&xnkCI2_h$HNe5U9@IVo0XVlyXjj0hWppmbjes@hGvQFQC8sWkHaDsg zL;Dws{pZ{&3@(nlI@@?k=xuaecxV!q(J-odY>}$`ip?6a1|BXfI}*0AlZLJ9Q2Jt9IPS0H}C*@Gs02h&=SLJ6RafwEr0=I zWLY144^pgKv0!4wNvsIm(B=i8rJPQYPk||%+46DB;Cj5Tjut|w*z<~%D)ePS=maat)i^+xam|VDwYPr#t2$a6-4QLDp9{+?MDd_sZSjs+7w7T?a+PnXE_FfUL4*#5POVjplyMsMHn*} zyc0tl)cHU}0rYyU3#?GNO9N8d5{&(>e4_g}wv4^RT_gVzGzC zll`Jy%%BXV$Fel)u=lVcnYc`)24lURtX{!c)Sq%Z?Uv2&$`V(PCiwEt{uelZ!JrDk zJQ=V$<>5g&bjE#aI1K7^8V>3Ehrr=!$7!TQ97dxF{>0Dz2N;hDvpKL_0euU&mf@OQ zfq~zn8dt@Ihw8uR+J=AQIfF1FNmv_88Psyqk5kS(n`GtZ2mgiEzx2XS;&VUtDQry% zqe=$Qj0J2pd|tnolFBc>KMe=^usCd@WBvGEaX`RCo^NYI7YMFL)SjWJKz4h zcy#|=n5x1r{MBD2jRQrY7|4yPert@ND=ibm?A&B4hFOm%BN1DZV1G)NCyqS3?9l(n{W*g zVQc$5CevLozk<*I*sHj8{h#4r{~>^1P4(OK?lnPCFQmcy|3%}l>AB+I_#rqLV{!S~ zXK?=FEAZaoKl|@r#sBnQ{~|tkcmmw(%Oc*mb&s_MC~T^3^msnp8-K46G4$StHa0$Y z3J9ATLQ7~G<}SSMs2Q++1dSpe3}%+$=lKF%*Wn94^l1WsH*Q9aaS9HKh$2dS3J$|i z`uB!Id5^{+=4S8!hoU2K%E9cx?BKn}-jfHI&-d|@KmKP28lxaIS77~9t5G>`jGT=r zeHysU6$+n&^Ravz%1fzn;h~r=$1DSS42ea=;p+H4&nUQ~P#i!AG!ZNrj7DShDi*Bi zij9=$SLd|WOgMxSmh#PsaRzkihFilS+!W2a88A!6Aq$9ShW8N%88Y>$r&C5l@_P)J z42%ITUVaff=blT~)&|jr4pg$QN^&Ygs4D|zz=pIwLxC|AXGF&aKp1E%Wx+G2dos9G zNNfC{kRnwMND?Nr%4e&t^LdC+w!8|nCDhJWyX{R(d0xSpiHq)18U~rH0>(LwG~LLFc1H+B1{L^{#Dk zbg&2Sav&oy#zbZBB-R@{uXZEcnf`(JMbe*a3ML_7=opo{T(yRirm$ph7p)sRygX+l;1JZj#sScCK{(JF_1b`e2E8wh zgFZ*kfJ4DHQ4=XF5S({_uvj&C?f1TpU;DM+z&mfhg|^L+zQh>4^Qlm=eGH>3f@mP6 zHKp-=vTVsgLP!p#;e3iLT^t(>C1XK>Q95l!%V(n3M!U)s8tp42yG+1*KF8g=_i^{m z9W+e?Qpt`SwHlOL7o0ct9L6<*r4Q;YGjYZtJs$;5(WKM)iPliiU<}UWmgnMkY!}Tw z@>{1lWE=#$+3_(RKHkIockf}jYOtEm(z^xqkm)}a2b5r>hE4*R(xZy^M@^l^={V0@X_^-Az5fALO%LY* zvZM5@O6_CG`Yr`9zb0w`O=!>pCbR?x&8Wf92P4c~@B((8laWD*1Qidh0krY*DMz;F zvD~(4usoT80hoFMTV+6WBCi%tR@_gaG8`()zQiFOy8HNSIOHz`2?VEm zw2Ai4;pkuw%lR=}*Fsl5F^2PA2#Y$8l?u;Ar-!4;1pvq8Ro#H3I_S|k^uZg_kwb<^ zYIYShrP%@A3 zm@}D^S2B1kYZ;V{q;+4oF?!QwBSt{9kFzc*<^AH1#LuA5iMus?e*Iy#WX1GrCR#c1RTnjkP!6=4eo9NohJ(SOn~S1D;ZJ`gKf4i-_&Rlw zHr$ZPkQ2T=0xy<8&u}E+NV3a@m6WEeR(Oq7{!vo_aP zB2kGy^0`w@fqMT_?s=9n&K7DZz}O1sE`0`{`osja0oV`kIORpWjzhjbwH!9#P@Z|p z@eDYW&apb^G#qH{q#=t7SH6Hxe_{$t0ISqKwLZq?A>vr5jv&TLo!v!JHx{)Gb~=6P zca%NxvKNv^D%1N>PX66og>^uPfO@=xa~F0n885L|vAS0Fl%kf7l&|9uBZ<$5gZ{k% z2Z}f-d^h1hs_bXOA-^-7GY0i|2bZtx;QaOikB>4PNn@meqFA|9!3;+FG{ftnVT!*2 z-)bUrV@0N+3;+!XhkA}mkW!2<)lV^CY`~3p5vpp8AOHD(16MBB*xlCbshC66SLd@P z0a5`eY{EfT=1d%7qbVG43J$TfXT%{*$UX%JYisLrVd+vR%z0Jm=4POd~L znF=(;9Kh+4qT?8mKmcK^MKPF?ntbjVG$}K@_aHKA#8ba}{r*%VMW1smmM0I;Ecfua zFa0b5z|C8?*DRlIr~-#j|I-x4({a#^KM=xaz`=Vj@h4-X%v9mc59_taB3;ZL&NZ0r zy@AE-2LAX@{)Yq<-Bsl&1RF+SC_btx=|u(U$cUwvrpdbm@Nigg$Y4r^YE5w((%yR< z9UZ||6&8yXCKCxto!i|38G|w~zWzLKIBkr@XmSa*+FrXiH)sC0RPC9mo+P^5CTT08}Z#aw2p@9iIAI-Q_dE%4GyFUw$H!ujbQ4I>MAHNJ{%yOXY!j1fFq zol+OI2qUi)6G}|Bwpbs6D3wzx=tw*jj1dtLCMOJ~6_9A;qsNc&xBvG4f)DQB$JWj{ zOhy(z@l$^qKmMa%!OqTZF{GTSMktSh$3Mi|g6s&j&5_Tz!Vh!CA!8&D=6J!7IaF9F z_Be_o(|i^Vo19!g5{sVf?ctYy`IqsX@4k($b0A~zvw!Yi!o^Ehu(PwRa9CFxLmcD_ z!PpVVZlP8JQp7Q0+{FhSVN(SMs%_vDMQPOWm7yIh9@uzLL5GeBHBF1j!C8LnO7 z(uMPBpelnA>By>wRiO-^Sv5F5I)ZmC=Cc`^Hs^eHE;5HK9+%fOMXEgm@M*0Nq}2cl z%^z^U1{^Zwn{d$Pxx}HaYwYgsV#{`LW`R9lgTUB5cRpw{`cOD@?FuKeBS_*>i>}*{ zsH1~49xNkpHFZ3-3rVxU3G4YnssK)m8 z7C!sAFQ8kU;IlvUgZP0LUj)DP7QFLCrWCVoT-91&9M4YhyTAK;_~79_4vrUiaQ`k2 z4-bn4Lt2Fc5yNoQ7%?La37?3AVTD6Ue)^$((D=G?RQ@SAFi?+bOePb2_A5Vy4<7E} zQ=fblU;JaA!Gnj70^LXwE?RAXLtwo9gCl(NTYrc*-+T*qKX`=Ia)sq$xjD5jsll+8 zr_7P=W?V^Lh9k#mDZ z=7_5p8U(*Axz42&X!DA|JJ}B#j&S7R_*hzb1t2om-Pys<{e_=K)AqQ0=^U&vc=_cQ zVXax$m_r;w-pHG8U&q^Ty@i{%-o>(IG|dWqvx3{i7%t>tO5=z+^U0LRrw@`-NKx>l zy~%l_5sN~Kk~tkl>9n*dn+#>w!{?PNmvQOxWq>93vaUxkMv`}j3=!>V1Zp;$reMb2$AzWD#~`Le_b~k+XSH#`I3};zcm@tbGt22h#%X{<_~+uq z^Wa1?qYBFo_Q6Sv;t(es>E2P(w0P^y@8Y%J{td7h!P6MddGvi(j3yTO7zpM<8e0-# zquHWRF@w;x4USnnPabL=E~K&q018qU?D8=0BROehCo4UlP8e|rK|mM@jP}y0oYg|> z5ClZ_xOMXu7K;_C$q19}i^4#@T$!y1{-nZGML27R=#QCHYn}h4Juh`8t#f)B4q@_- zvbLn^6dXHHkV2ir9Zg3rPnaT+z>KDC@$RjgST2{SCL?$_bX^PIcR^U>NQ|-fGHjS9kdpFkq~au5f74jE7APO`udRh3KWN zEDRY~Yv8>DI|t|5z{mi|z_UzFE=H|WfKl-B%;>rfo(WCUVlo-w{r7HTxm<)vMjf2% zK+Le@l3^?urcWA-2wUmab>cn(?*A!ag8^8P8#ruc=Ypl>7=&P$LFa=UW8)L$D6b81cJh3@ z4QnhabzV{!(L0INv|WqZ8Z@hr z0BtPVrh_pSm6hc!otnd&7~|oC4=|lh(RV#&vm-d?P}L)d3N-;B0VCR0V-2c6Xg#X2 zJ)L4a9)rSO56!Ye16fMZgxE+iJ`zk(-s8i7j0sF+04rt7yik(y5e5v4#Pq$xVzofm zwP>3LzV87d^sWap;KG`oTL89qrl_qnc)vPA(>wHi8vxGM{$PI}l_3y$xV{5>FM?nU zOkeVmkIVhnAslQ_2{b_1`gZXTiD*-!hGq1W$*yI zLO`;Q;aren8ABi<)Ro0(G={MiVz}1#ZIEJ7`HT?$4FNLe9mG_5xV{heqEDbwU;r8P ziD7l|E^>r5C*3fINy|j=ES?WXhmV13JO&KGR297I!OR$WFRC)wTx$(_@A2tRzJlYU zL%6<$?|XRX08_yjwbqjF!65X`p$}%Pt}BcuV~odRX(*D#-k|FqhXH~HVdhkyI32?~ z2V*U)tzcomu#jkDhk8AFo0?1_Ri2QWN*Ffq7^1SR+B~Mq3?6z`p1mYF z!^1Ohes>Gg=@=*T2JG9=qy~C2@2Il4eB}~sRpa2`5Z*iV?F#IDFdeyWx>w=7G*sXB zSjI-nVfGIh{1=QZ4e&}3tmyGk-V;K2mAzK5-{Ste7# zkb!59zU!nbY8h2!(KHRXmzag~qG`yEK;D7cy{dXiW0AO=lvvlqZ8N{HN2!@&`5ggBykrcYc#i$fw z4x|S=;c`wold{P&CaA~A7>vu8FX7zoHdgHntEPivO3qq5R!xJ&2OnU$T;da-{3I%C zadLbJ*LU!)hwa6Qj1Ht{Mr90)tw6ynfW#+2DBBp(9C@Nd{6i8dj*g}QWVU=cvYW&7 zF{_=Eu(fUlG%HBNA@n9Ii%~TJCXCs!jE7iTfsDh}bd2rkI61Y(SkYpf2hq~Eu5Hn^ z9gdC;ar@RyR8@r+UU~^tRiW=%crQ!xu;-)2`O4M8zlal)Rkc$LMr#DAIVr(N{4k7erQ#?*75oB?8E1arjEeN~L@RpN z!}T4yW`(XeFdL#JIQ5;Q6Iwv6a7n5& z{c&+E4x#tXqiI^KnkLOEi#?jHt!;eut6zod8-N{L*Pv@xXjk)8$K`U4wr$XL9Tq1u z+N^$5n;L~C9}C`fYhD=dJRZ=qNy`3E`ctQdkP{h>J}Ylqa0&74tc z9rp0PN8hzrE>18zI)Hbb#7AreV=HWLZ{tUQ{KsG|B^=3Em^$aB43<=2IOj1tIl*GF zz;dy~WIDx#ix**yfp>j&2$Ro{RU9h@Nonnx$iejt*qZK8&nf8)@0DIRCn=5EDS~Z` z_(hZH1e5UylaWPL*C0E>%O86MThp!7fW}lXYBDc->79A+&~_cVzQg=v24gEsws&D{ z4bKL83s7M_k{E>QePznaE^#C}QO5Z*e}rND6wTy;@1~I{exy)IiT+etK~BAB%`twoUN0?E4WFmj3JOU;Y*m9y9fTnxoD+5WHmEuAo}1+ocW&RrCqMH;xODjv+OETLF-PBZp-V{8 zSaAbc0vI;?7|9T3?ZGT@e<5Cx9(p2~Da0w|WNxB#WePp8 ztK|aqXpBj1u(dUXt*7uXsO&^`addgXkk}Ok2tD;^niUTAp5WT^FJgChD+obwXrT{E znj^KGV&vozBt<%Ks6(gO1e|;j4B@MQV|+43hGUP1tn|>lhwIy<%sqSfwuS3EIPbAq zEn!TB^XD(1?GYiq|4H;Zrvjj-I>$<8yY;ZC=#(TGK;tOB;3Z8%A1drYV7Ww$At?QG2PmhkT_8qgD9prvW&TSFFoD1YcZdn;9&m= zUjEoCxP0Xj9zE>QwN0)rNaf7Nb-5tXbWhb zgf4Y%g+V?B!}Xn1R2JLB{A7lAZ{ENsKmCLF_$NMrcixgEdAxIQ1^`TQqGFK8drA>b z3Bi~Iy2z9?H-|pEq3kc1(Dke9TH+F}PZ2#SLi+CBOWM104t?y2gs5L~WW#-Z-{J7^ z0EfqOOeSNAW01`Dj)R2pFk!t4iOWbFU`0#JPfoDE_XJn2Ud5%$m$1M0Bs0IpP&-bP zlYJ@fdYd$8^p$L~EQXNArN|i6l|@}yS!gDhLKi~#>>ZpF4EwH?o>tRH{$>n`hq{rm zaK6XUe1S#NqwRW(#}jzh!Fv}v7c4!wA!B%0z_rp4)d)^n9n1L%j*pLV_x(F~cWMIv~cq6`tSQ&U9#yk)m#9nyAq!R!m3`kWNjm1&2)d(hNhQ4j2 z(K*=1B-kQ_%naA}p}Xd#H{?CMbEu4gGmLld+`%7w_YS(QM^#lgKH7&NplKH1dQ_-T z6QZQO;}EfBbX^Zy)tJp>*~jOee*u@TT*cwRfpRW0S45hLV(=^XCcO86$>tHrqpop0 zYHNE3AFw&R$OLGChHz|=6+2=%B6DG5Qx1ZP>aK`%Qz zwmnu&Bh1gT8Bb&=6P;TUCNyD;hJ!gwCXE@LbGY~Z9h@vyShy3+SAaDJecOaOhN$+C zG1_Kl!Q;vgUT%tgy8;#nr3V@Z|9W8Ig-x(wpEghG?i~I2R>C zMmV>&wq-=Is?j=vYnSk$f!l5YqJ_NQ$VjB|edwW+>el6MuX7&9vlAFY*g1DTj7T!9 zk$z3rd+a@Vgoh6wqn@6JC6AqJFJP-}(XQl=sIbf<#LI> z6TMZ}HO7+(y0(Y+EThbI4JHG25^w?$T<|+4<1uVK#(27o>12Y@s775`w9O(6I0Ec@ zEau0+Tufr`2{El-QWSiSn_3%!!qRE5Aupuu2rgu~OLm!P@vdEAaWcaQTWFg-7;e!n zkI}a)c;6>sD;UGO9?naAfW1fVA#)grir>Y4jVBX{Z3GWpm?5N=2OHNr2R6Z(W4#6_ zg*Cu~+ZPv3Md*WT=M|fe7{Bn$IGHbC3}Jisd{7pu{>*4r3$PC(on$3kO)*gA%g}QO zVLRv1g~sx6H94@19*0OHKq27Oi#{|4bzRBux9{`R2F^Z}jD3VSgRMjenpls}eGQto zcZ}Xc0&5{)A|s$NDJu8YB-|bHKD)Mz6S4Rxec#KXy@3X>3#P{h1$!FW9~~bZ9~~bZ zXB|n2e)H`+oF*1g>~2yT0wzN#jW2djqtcJ}>F2V~9bQEFTnwyMj6a1}T{9)X9`w(P9x*!9;{0CCK>dXQ|ygv5kiddb5C&T%2 z93a`$@Y|G^vlp%+oy8}Yzmv0x;rY$wGM5*I0NQ$lx+a;9xN3^gRTWWFU)9OtTgVS7 zR^-)1F?1+@hy(t4;Glx8r{NHpL?Xl-hAql+ift*@Y8`|9oPWk#i5R3ZM#B^WeewJ?Je&CJ)1!>2Cawq*Em`vKwQ`$ zA)NaYNy{LFZ-|o0)4dH0u6Zwm?>s~y(mDV_tK#Ac=bX`!!LkZ%mM1u#ouKUiYXIjY z^sI@d4LIbifLPW#4oH-jI8gZhEI4pM*VA#>$Up;z7~W=v*Ki#NT>ud0|;@#3`bjI~SWy#|SJsczHk65=0?18vH%p86 z4mZC2_wn%lBdnGV#(S_YRlJSsd#{QJW zFN&K*plp%|S+169F3OB`>M@Y8#mO-qzyF(<9i0Fn=2SrOn1BO_MZPK4u5^u>a@=n$}}-@ghvq11BeNokwJG#Te_x zHGAC}=TtN0+$f(S&^2_Z(t@wb>ZEJo=Aok(|5c9vfM9iAm8YGql>Y$6V)hVkedBlF zTZge_EXsA&i$goeaTZ)5iJ@HDfJ17(fI}hu@tkU@H5?cUsKLKQYSln>4sg&t7giPM zU5nZ51Ng2*-*u>Vu3=(o_0v_I-=j{?T8-y*o4bUT%yIW3fs!vac1pmZ__%$xkqZ zmR2|<4N>5rpVtz+0f$sRuS3tLw8sV<1}kGB&ULx@`u~UzZoiGy+yewyJOyqqh+V+MM5 zICyjyi-pIkm$9t6Lb3R(E;BJg#6fAn!lXSj4y+nzhyx|Y*V;!{DATD7)AxXb>m80C zd=rNc=9tZ8{qS+yfzfStJ_~(KSrsz!_6h6yJNybaCr^X5ijCjp`NSYp?y;6i=b(@k zSozxN4h3!cp>8K4w9BO|9|d6u+4oE~#M^YF*E8}o28F|*e~}v(cs3lOW>$@@X%)FD zaga;jI}TuvlP8a{ScFC888V-Xu$eJLO5%%?b}T?4b#GLCS&YgiW)0#nZ7c8%gtzsgw1it@F|r0GFoU%~-<4Lad`7>4HsYY~->lvLh&V7(@T|Z=wUe%; zE;m!IPY4d)RaiBmHNhgF&X*>&=#_B6x%m~WrC8rdRKA$A6#%jX4S|o(tT#dlN;Efe zdsgWhh@`)y3H_tsaEhYXOa>Rf&VJwEVa>b*Lf_RO21XVbkAU6-eGlk0WXzxp^&{F* zl-)_CA~eyuR;6(%tE#iF(%h4?X?>>SG(e()t!$02{@h=Itz_?Y&p;a{;}74zi3LlQ zyy1l(5{Fbl2oA%siqnqo6{?7Xttx!=XZ}4H8wAt=Z6l1KH?kRQosy}esO#d#;QNs1 zcrK4DqKJcRfaP=|HeCEL+Je=8h>S>tOE3N;zWAfBz(E+J_rS7~zm2t1k{!RV;ZSIU zXUE|T>ou&!9{~rf;{d|tmwy~T`cof6(*gT)p}%vdGDd!7T7v-+?#{$hR;8L)#Y`Ho zSZb+G2l6wpy}G=$%OBE~44;!!`dwVPWHGLRaUEkdo1dhDcC6tL?>!3#3}`8E7;2y) z4yRpyW*oBC*I(+<1$aNkOV3v@Vfpd6MCIBmUcYgVV}lyq`038le3q z70oO|DmOfSCJxE|63$RVL1w(i;gdU9E$-vw@LTv>f9*fV?8IZ)0>61vUUM1_xw`p# zjEa6wIK=0_pEzv%v3`M>aeVj?Cx_Q@a_}bpn_v7-aeU&@JK&$aH>k~bZU#U=nR8UI zOII>UQYgY~1S3DMA1+j?s=^S652+m14<3Ha+|zd}c<=D!{@3t-{?dPp+wbn-fB!Yv zO7S~4@5TJubpfHQ@j&a$@c$ni^NvmwF!r;8F1ziXT~!#_V*uQu{gq?{EMyOTcNL;T}an zbg-<+U0mHI+gPbV#kJ(vP@divf|0k+j>FT;GNPH*C=D<3VTxY~6EgN>z+sV)dVCH| zmzP1%{!z&kr{fUM$HnKuT5|aw#T-M%8RlMS9AcVa#5YDzjZqWGH{f8xmcBl2Co#k! zy~~8D6O5hRtEk84)3vohw4no)Y^nmu8bWOaSQ{52Re6w_z#-S>hK-TE{0UGhFV-i; zhC;b+ldT|;5h`}NbbjT`z9;>BdjPUO|9;Ng~97dJk5PdGa{^_QG zOuAU{tWMMr`H)0o3}z1kg_ZH*Io)5@RSln_17X>f#UTlbS~?Qx!9l$~91dOA;^b%_ev`%+D{jeBQf4G+ZKoGTi7!Yq z(o@A%nUt6H(Fr7}1@y~VU&GoFiIUKDlsGP~>Y~!jws6ifqiI^a@%p#%#v9)St2~5} zrIeVQmr}}r6Cp425NOR+3^;^~B{SsO5JkL{2??~&`t9jwNgo$DtoMAL4Ts)&+<*8O z5ANN=-jgTknVj<(K8y4O=CDLDC)-F$dSarE28DsJfLl;kL7L;tY|TJXEo)M=q)=j# zq^E+tjK9Pn6^p~=8wxW3#D*ZOmMgsWJFnp%{lkBNcdp+6>#;=6tMyPSDj6*U7h}wpWk?!2|8*pHTrw@t4Vu^QdzKh@a);oCn#$B8o zAE&3))>l3$X%p01S~(&G@+p6CHYbtr2&yb*nt@6Vr)OENFao9M%$g$RjjFa%L~0e7 z9#r{)!y*y+%L6VnVFMFz{}E~GQ_6%IH92RsW7`Or8- zZ60@*U}7|Fhj;Jp;r$zLplMpA$%}jpMOge-`vNJTkiF})-DDsn_LejlLFR(ekNd3! za8-uWT86ql62tOomri>y-=i9??>fv*W@uIo*oaoxdIUVJJQ40B=OSpBZZ)P*WmeowK?0Tcri z-PatPD`-kMFw$I{lukc9)YODz(w~mQCPWE9Z4DN)#~_}A*cOZJ4|X^jO1h5vQK~qU z3gxp}Le;2Lg33@1oUS#h2F;-jduijNZW_LC9To38TpHiPgdSqmIvIbD*O>D(!th)) zEky%{xituH)%HJPb2bizTU96sPmr=%KSYRKgRqoY0_|xyBy0ybF!z}9cVYYY;X4AU zDZFZ@X1_t;vj)-opPhOr2Z)lRI#i3pD$6p4;#IHv3LDjVNgmRo^c&tH-#KX}05Ckq z79C-_cps)M({gl=NmVh?M8T8WjDvQzl+E9SLp--O*+$;0>7n$1!l6(Nc_!e8!GZcc z)ZGU#vxl%<$=JnC&Fh89OAX4zx}j9&3w+9jT(PuSuf{~D3vIR}n>ju`GLX)7LPGWA zMBg5vhHQ=1dLF2Et&*rLC-S-~z9cnj!qAAc845-;HH!BbNc&W%W0U#WRNflF8*oVM z7Ygxift{*>VFs0ZedZ?7>u{!SS!j*uv|#Oe@OG2OpDPlhpjq8TbHMp`G)1wu5WdHU z!fRAeY`u=UPO+NWF6n3DnTyvxhoAqq{{|)_i@ql`dHq*a{s0pU=^kRB{!}ZTiUXH8 zK;uwg!1{8eD1Cky9CCf+%yTb)89)DT{xzK6Ue|z$F;Y(iG4{V9_*C4hujKFA>tI%A zcyKGQu97!M!|+vcEMx7USo^0G09h+*_u|L!>MQ5LEUP3w>#@0ty!Kb9bk1it7BU3- zY41G?r&Dpz>Prf+w!)=rU&ar7;sS`Mz(GwoDu;*%J>tT)MqlXcE|RKHQ6Po?*WQqP zn8bh@FV4}ySWGr>r?bPRp?@Nv9!+rW;w5Zv9bnnG;-bc(6ox!-s7QG#4r?YS0=NkW zdNv$%F)v6DB}ro#Qrl(5MoNJkq<{{EaIn4} z|DgXsilQ(VB5|_QU2G;cltviT0l5J;;w7ux{e{2$@8kTr5v3Iem)@mLxT*Cna9AX)CCJyy@3qSGa|1CWC!i&W&4C(e;w{9m_ zqIjtWi84UIx};?1CjJVq2s9hD0^*!7hmXF4 zKmBumm4-N|>LY2*XTTx9SK|_-{5Hf9v1lX zdk%5uF@OvdAd{QZfK*h~K=Onmi0eVGYyq?47x1~ye*u5;N4|{R-JP7XQBWPfO6mF<10YkO9-qf(dMUljdlk@%^`?kJC@Cd4 z&>9Zo{cX3B(26)^YII+uO(A|A)VV_wU@oYI%ausK!tK^q<4|3m37qwWV+v z=p2stZitRj1!KpU?!K6AFX9+c^SPiS()N!^ZnSnJQqsl9^EHRv7sUXlA5_v2F(z5( z{A9ks8*ksl$?OR2at>qON8fh%$`?P2-QAtCZmU&Ac!H`Ojk9Q^8?I}pTL+gxLM-UFFlv`Ys<99{EV!6hQYh}e2M+T0~pt$Z%$+@ z4uwP1lF6J>uK!s$Bux}?2uxDofQ*A4a>$@ztzqM}s;aQNyN%KG9Gu;SCyVL!d0f17 zC1;PFg+uWjXxkR^*%6%UaddQylldaOuJAG9XUeh;n+Bvou1IxgF&+?{nLwEiptv3- ziv~cfMb0%QAzEVzsH+O_9WIS3oPYQLs8GvrP7B_F*#0MWdK;N~f;GiR? zvYcYBmkbuhhRj+rekQ<4mvROUlyT5zBVFUON17{JVfWm5xY-hhE@SuHxwNh6Iu1ho zP;)TLRvWi(zl-^N4r44HKiS7@mUCYdW30M7kQBSFMyX07gl!Nwr16*8&1*ce!8wf; z^HC(BKCy0TqEe}<3X{nMpZ(mIu(!8|&;Q`7_~BPy#rV#9K-Z`BL8C;(WSguu1*pf5 zpWwH?_BFhJ?*SekEO7VEdsv(tgVl0%ZJ(E-BZ}Et(v-2+Al-LPsr{Z%;;;b=<%5ptSEbZ}k-}m_X*MA>xzw-_r?Ju!dwrHCb zn0t_7j5K5r26H?Z#-lKOtUhBCrXW**ixv$KPL`Op3t zG);?3moCcwxX)b$2q{@8Y0CJAj03=U_s(71diM_AefKunwwJ9{T{(!FH?na#P0s`g zpr9e5^mw8|Qu2r^haAX3TT}U6FR-Q?GJ&T(boG4v{rt5nvURs=2!{9t5lIHGF$MrE zmMgsV&P{yx&Fko$?A>9j3Uyt9ko}b2$NfMk(dVheflk2zh`T+N=3t0J!UH9HWUU*c zubXkWaN%OX1Sa@dE^1;$zwkoF+B=VvlR56be-FpU$8esLU}%@7$j6A4iNpPb$Qs@GM%GNB->wOcYjU$XN4h^0t8RkdN9h#%a(r>v3t?uoox{UNPh>ae zW(DV2_5lg|fu-hA+$udIUdI7Qnp0{N&r?cXiUmbQlW`Cbo`yq|`XLUj^ZkRfBh-lge+aVJ1o zoknsOZP*)JB6=Y&(iqtAJ32+Ah+&opATy_B7p%21(aD;yQH-oIhiG2Nfm2 zUPB3!xW%`%veOWektqn3P1gaT56F9`nLtmRmmAAVi9un*AZu)jl{n{QH>S8ja?qi1 z6C&9kPSP03ShURwe-p?Q$Mn*Ch>6)pgkEu!1E*T}y+GMq^lGP*oMkknD#ZeggaPD+94K(b3v({*T9a~ODWBuyoNa|aFp$K(B5??9YI z3&)Tx%R{~5S{jwj3}F(z28N965?xiWM!s7kPNS_xvW0fo*Onp)VA-802t=H(8UPdK zvLXSE#JPsIsW*3UyVZs_MiT)>>FAI|f(QhOHx^ z;`$-_m$sGy40%|hw1Z$($zH3*5W23F#ZLs!z!;J#>OBVlL|URM_1>dtRyaC5KwVdu zPIuq|WeuA0(E;pW$iXo~FQR?_&Ml0mTL4vPx*o3YgGQq?ZBsVHBUC}tRmPcrNV}-G7Wu@G>--#g*nY_h#RiUyKj5UCFaD4;c_W%sYGrY_D-FxR{ zdoC|R61OJxfll8wsOk}{0lKCGs27GJn}n=jjCQp^yIR0hBiMR`u3f?R9Y$N*sH_cp znhF|1!)Y%T*{L3Kram&~VU5g1}=2#S+!!W+_*Cl*Zz*P~|-<4y8q zjICfz1wi&V>-!FU@4!Adj<1LZ_ zak>x~BaIobgliiYoGlX04(}HeMQEEQONMiBy@PX3n8Jn4{Jh6=&t1dy>$l+h6y@z-eWO8Ny*G`61I-rRdHA? z7>u3WUGaOu=7wY=Q*YS9d%l?C#@lb=OF!~sc;(fPTKWH{G@ z$YQ#64t={qyPS!mWg38;Y=ap!XS2fL!9FH0yohrbui)tL0Bzd{a|D`+#9ykctVCfg zP*;??&$^f6dIxW0S465RIPbxbR4JJoYrr-~GIY!$C=Ed2D&Z9dyoX~BkzWpiXh8J{ zz6!lZFTP>huHbzKkb$*~?UD9l0Ph@#NlaBZ(zm1I6C52K;_WxSgRSjteC*Xvpsq(U z@wM-QL~>Gj+5d~`DsDh;fGG9h9D@2cChjkma}RPXK$EXh1cP}T93)nq(9)S2Heb=I0EzzuU#Ba4+;PJzUxOw9`sIr*u z?7+Dmecz(*8g$(Xeb>NsvTe9%C1Jv7ZuQ`i>?J2YNL5#;qu*o=jHM)4F}j?5Fj6=x zX5-M~ke#x7xV}T%EU}u;WD73>s>)(Ko?vTd7dyM>FrG|N)itWHp2x*=yO>VKqIHaH zlkQw6osJ=6bbXJeS>ef(hj{%DzJZ;cZM^*PPr%v=y>nrZX0WD3P(8`UD~XT%lbI-M zl5|OFmvV?d0x=jO4xzl1^y-6(Wgqr>j}6$Reaek7;%rqGJ6j`cZEs_`dl4`OzHh|F z>dd9Uw5!Du@7=r!BErQ>moc4AF&fpu;bax)q71&w7S1_z%@TW0p5Xf1Z()0T8!x@`aS##uzJ+5C&$5|r&VE)E3=K8RoQ^g# z0l9XZaxO4~jt*up@}HNEsPs&Fhqhf|xm*ONQyPFhqpmEz^yRMrAb98CT?=*{^c?@} znKRm^!Q+PyP}L(`ymSTQ@d#CAV2Oivl3qpV8K*yCbg3(m0a@9y+gK~i5quAcgV@5v zCOexnF(sxL9x=p?fi`u0kFII3n4e%aJH&E1hxZOuJ;G=*#rX^8@#lZ$XJD-nEfdP| zo19Pa2WoidF*`oQ;lVz(cXu(}+Q#Lp&*5P2F?`>Hs!DcP4qd?*z+wu6YETTL#XSuY zs5pe8Ht`FkfAaM>+^Io{pD|RMB8?(zRb7QRh_ssbPJ%q68hsZ6d3FTvd-P2~!8Os4RC8#XqUWtxoiDK^$F1SnINnprIjYp7tPlHl+?2RqgdJ+4uB zW1;R8Vl1{E$(&SXkgeg{7X7NhmCKhgnQWnHy5IuD;Fui6AeyljFoeEqvA_2i*Ped? zDp)Js?5Z9I zGwaYcOU!38c<*p{G=r_jF!coN9E|E*6pWF*Z3|+D)pCLNZe7O@eg21W?b>r#Ef(n7 zRp=|bu%K5+g7XB9bNXW=CZM#I7|dr2iez0M#B(95I)s5mJxNbyREZ-O!|HwCr&64g z?&@+W>v^tLD|lv{+ugyz))eE>2)z?u1OQAmob4W=_h{P|`+Ix1a`h^vTU%(F75c6X z{uQTg<+{e;jDdGxZ@^yGS{CJ*{gA{QiI0R{nYEYi$x1ait;WFGQ3}P&3OUgq1p2;% z_l(tQiKc0>T%4e?2IntaMALbU##0!(09BK~(|KY^X-ZOyi0VC=9pk;5*YSlfeHkyj z_!8RH9E-&~^iFb&p2!A}g2cpj4>m*_+bmz_aV@hx@Srg>W;PtxR(#M607hAOxZ8+Ec>Vk zp0chNLbuc?y%I69)`kdcoua7BEK%XMg>$WRvD+pHNsqQ|U`&nCWD6HAUdH*0S1=xr z0d}az+tQB_}{EBGGFIkj&*9)p0;_2l%qTCMQp(L-Fmat)WST*Z?|4@GktiT_e) z9Pzs-EQ-Q#PR21RD}#~27ms3;hRS6XKa(8V=%mKsh2+qyb7KwsVnJ1Y9Ily@Dvvt~hnka)5@jxuCou#ImccSRf@6={@7={e{n~42RttDG=-VZ_uF1eEAETJm ztYQq_^;j(yIM{!JYuBE`<*Qe*_vj&iqDxaN#OWh+#*Xb5qPau3UU zFF`Of!e}%?J<4NFCYWd!1)&p7==(mKo+!*dkC~7$fOCmaJp=E&a~fbxINApFxW;5WhI1~h|!JJ@aAD+vL% zdNf7v9A5kSw{dcEg5#roSW}_vmLZrE%2ABbG^@P!n6WaB00U-+zL#8%<#K`XWQuba zE?{wTEZU4E;e|}C0#uUW&divERePdVsH+O&@kDwJVVK@n30cNQcD+N-Vzyk+TpUdF z=G+TTw4@FV3$k7B@Wxv=@aXXq93LH^={zo8eF?o|bZrL)!MhDUhOI`@jq_geEP@J; z! zu5FT9Tm)yZm;xDPf}-{bK1Z_a!8h^Qcd2R_^r}V^93Rgxn=PqLR^ACb^@{{^{&LXildtzbzNgL zs*#dIN16G(z7sG74LbK*V4ldrm1xk^+QzundK8#AygsWzn<_^F@RG z{R12w9fCb$wOoj)>09)z_>Gz|RDeqcq=w53#87nKbuxY*#ue((L>e^b#|Jf;RU{)z zH9-|LVOR-v1x?0;;WXfd4gibM)NSe(3+&B;*PmdECJM(UwR3#3QI zl?zEbEJ@ByE8`_hf|a!vCi!hWyj$DR=cD7Jqx>Ns#a}g#Z8m07*qo IM6N<$f~foG8UO$Q literal 20332 zcmV)%K#jkNP)yYZHYsPYB?Njf5FjuTy(JGp93TpUJX#5y zmlz1(!~&8yh=U}610;@NNiZbK5@}LRiG$cp_H=Leo%;^|z5jHEy?0fuJggd4?X&;e zw<$gL?!NyyXAiZjzFKpwsscVdK0H1=K0KcGp!DS%Z`@#jKm-^f5D|)Bh2--#);)1DprGd`+s{JONdJ2^=d9N)2KF6f4AXe)MEu27w9~ z>Nx_C=!W9692?CX8Hxr{tiX8hov-2e(FyvYFl#y{2_to(G>fVW zO!1u&DFf78!I5ylnTs~dMgcei5tzXoE6#PBbo+VS+6iz9u$i>-EknP;(LsmBO6t*= zv$-SHm6V2JWdIn^eS5*IM!naCCKe7ZYT-UrUyGI?)d z)B1EKHr+21)WdYWqBt+Y z5lD)KN~r)Cfoq-m1VLVdL8!!NQfs=DF=+_k0dN25e}j(az?MZ=Eim+4iBW5&Yml{J zB7S0UWD*nacq$y26`@g-EQna}_>E6I#@a%w8s4~Gq(%rH-}&nQg8O%u@B=V2K(Mu{ ztE4E6m1HD_pp;9JC1L_*SPS#xk=7kXbDknbMbF>;GSSbd!$2v&cVr}y#ptFIz3!?wVB4S22%&|+gy z-5ugWZFWquQHU8F?Tk*KZ7vR)5pV}QQ)Lu?hnmw^f)o|VXn!OV-6t2^X zC>E*7Ac<`y5vsarl{1ob6F<$e94< z-2HN@Vm!``uT=}@>-6Wq!4Dpf-+L8d@R&6u)G(76Mo>5u!WhXZEqX#F%13&2)O1pR zlPgy)j@882reO65>Ao0&HhpY#m^zF#Xv_)-4j{k)unmDMf#)rN?u@c(zSXr+Y%O;t zB1h)$cx#PA@C==J}?fdy6oM&*q24P*_d`88Twwu01;lQdQXW=js(r3PtsKkn%gu_TUoOv{s5jx^M;5?g4-DHMys16DJ?Nm_eFKCp$R*5Ox@uUm_;+n2Oe!<3cu%Dpv7? z+oym}G*gH-z#78tHt_HSSofO%BbB2>x0aqssZ6@mx;1IRpZpT34A6GPJZjtkoo%@XT2+#Y_^%%jY zGatj=HeqWfF$~iHw%L3>y)^6p_}%31XW<~<&C}rUKCS)>#{p88XTSev@q?eb4DW%# z0sR21dZ6pevo&GFx27Rw%8e=nl1WAdzp4;#DrSPCP@b-AjjFYLwgmd12bGrs02U|r z(JlA!{BsujJ7Q~uAi#{D%jn28t$s0It9Bm`1}$3xGgAKmypj%ZG`chgHKN2PiRPY^?L zA%uYC=>dlIeGJ1Ry#3lg!~MHAV64G^^q>6>0l>H4zQu_dG!D6b?}I~9yHAV5hKicP zVN-jZjm5@~%{xN~IC^vg2Y3G%M-RV)fB0Yj1Du>Px`FY^O?gcn`OH$Gu1M8^6&U5* zWIh6IHkYbK7L#rQ$M8acQG#9e^CKzLJ@ybWBckWs65B^R3SO4I1_}~Bj-@(nB z2f)p)F5lDpky=_8kjJ{Coy6m<~Z5?X7(j7SX9 ziN;X%Y^nsSj;pIF1?vo{x*k%Bl&2_!03)>#!zqpIMuN%i=ZHu$QO0+8@8MjJ)oQ80 z+Qg&M6bgsd8njm0pNQEg95&hoqt;sFRBt#euZR_|U5WL6Ybb*HlkD!lKqeK)&aXwHws4?1u zqulOPMt*0kTS(H)n3!Ok2%cl-794VWsveb3js#GKV7R&^#YHHdI2#A~8$uAG;0eO8 zhH}=jLCl4;1RF(D0HcM7FewO`2tpk9wVqZeIf!$6Ri5BY?5jt=~DQqPIeqi^I_<5^!Nb56~8?b#>|!q znW#4nIE!ZjKvr4d%m^Hi=`{kA^&61$FH&D7N}d8~{Ee%#=}h4w0^-=sxGc`}NIbq$ zz*w(4{Hs6w67Ju*0p{Ys&n&^S7N($Xl(O}IeW9todRwjQNSNz;nqqtRzFUZuhH zd!}p<==LX;Q8^k3Lmm+TsUNJ0T6Y~@ef4$RxpS*hMMYvj2FX(8Q%7>tUD99^)r^^_ zNHTc1=6GP!lhA-vd|ZPuNgVNdBD`uiIybz4N2Y)-#u>0$5BTaUe}XUl;UD16t(#Sc zB;qwmC2>ha3SQb~Ou`}RcUu&gmJZ1P%=w>0Ik_fbG&asRaTx28*un~@SpKNvb((g0 zhe&@SV7cz`%GdrBfAFuqgxA0O8it`{EM>y*%-|q}+Js0^>`3k%sEBk9wOulj^i>BU z>0r$T701^?kJR(JwOSQKzAcR(ftx!o8d#~0UDx5p&6~J+;|BV^M^G%DJ2RIlN?N

4q$_H4eJ+qYj5EOQp(I?Jsv0?$y#^OfW_GMnj?~m`xnCT4G?eT;jcZ4{-g)Ei9L7 ztd^(wxsoML1Q$hO;8Cocl#vD9@tnC3it-t?y5Okr0t7^3kh7_!;!);M-Kfkuid!li zSvqcvzvhtC>B$xX1AX7&op;_vw_d@IM$n2E!qMPHG_2*CRmNuaE@Iglhs^br+9U`H z1{|W~$sU>Z%toDCRdLkb3I{D!Y8*J?5Jos$zx5tg>mJTCh{~XmO6}vQJBv{Vr4p3m zEaxJpR8wqB|#Q3?Ap1rejZ=V%D#$%X!=bXZ3Sn@l3>j8cH>AZJ zilwRLl`_C`Z3;??(Ne35u>{n`O&y!(wdZq|*wuFz+kxCe%-t-Atif^k0b#m@p*b265FTy8alx_T*ub>0pNMs zU9BUEwbEqOZ0YMU=}NV<&z{;d3M(P`A$0)e{f|k#n(Mb|fKHYXKi&EP%=#48Gr(Ns zAqWn!-0?dJ8xx7@lBcP9(8KpL&l@JmDSanAab1) z4$r>ybNH42;5V^7Cv?4Jb8Qet@d;A;8dXM%`lw%7*IK=J+>prv&hSFcKF;I_I_sLg zAa5aKv3LHX_)8y~i^wb{MwV?Ghibwk4jK#H=KQQW^8_58<`~if@v$9P0#rm?PdHLWCN^{alJ8!`j^xt8hz$BWF5|^ZwG)fd#ju;D&%Zf zr>+}g(9U*o{^B{b?HbEY33`o#sz@qljKfoD0809Q69+mA2UV_yomHr}#^K4NHFcd# zgZcJ(y!g^(T)ei#y~jnT=KfE~tAsM7oyPVP80B+(J?cpP4^*`%V;ozPKF9Y}l4?YU zGy*Ast?QNEpOa@M@gmq}hM)d7{}wJ?YOuYfIvS&Ab89qh$09JtF2HJtINgg(ZAE2JzDDU!mW`W`gb;9ie2m#_ zhNI(Cv~7c?X|TVy3u`TEb}05Y(z}^!q={fmgZcI|XxhCA5I2QQ9keU$sc}fnNJi!u zhx}79NGvJiKCz>^;R(+IDs>^DLj)p=+0OGg|J*MD z_)P#4gu%>tT!huR+(=CvnPWIhg|x~{4eVG|Pz|U8XWV}PFbod&??1r*`G5Tr+`D%d z#K`}~y{R}Px|)$A zbHC&;o;oV3I2#A9>zfYmJRUxHfZzMA-@?_`UWMD` z@Yb~(czp04j32OV4DQ^ygY^%8QZkzK5|U#giLR_pF=P%1-oyL4PIay%>0&D!a@$os zGw|bPj&Yebd^T^^eA-?&>>sa2qk0Bgj5gL5+)wl4O&wM6JH2o}(3aiyT zAq0=$9RfE<`JaFub`MaQSTT=NtH%ULTyra>Imin~?b3-ONk3P&Xz@!MMd*$t2COyM z+M1*5If7aVe!#caa_@AP@EcaZxhT85Mf)sQ{?~Op(t` z%Zh;siNX<}*{sFZyhYgA#)a)|Y^|2qnNf9L;)s9}_{lKD@53Q2Kzh~aEu5T3+AClQ?~BjUS=N*m6Stjr81gPn61&~-hUzJ;+3 zcDCoRafDbCLdX&#y^3==-ium#^ym?W!DH3+=!c1%&)&sgJc(2e6cctxx{@e1G+xg# zHapvuxd(l+a5UwPPW9CRZQ{|>@!9!&j=f!v&wS=HcyR9yE`8*qIRDOd(AGIP&+wt7 z3~(gy$`gt8JUKnZ*IxM(+&ehIqoW1x+`Nv%!^c%lM(;!JDU&*%07?uIX~jewI8GU$ z*a>t74obk}_NGC@LJ)GtSvYKMZ6Pr5*~sy0%{^b&xUG-`m6A__x1^yYJn@3;XA>`1&`o_hX;I&hAb$3JW2Ww7Eio z&U^gXx4(<;zHt=~j@CFiUEt{8KHSh(b<6{d$)G0Acn}IMOhurW*zSS@MpCm@{)r(r zOp&-e(?;wfvlU@ydmBIZ(?5$(r6qvNO}-snU2QE#mjn#R~6Uzkyd?`3lHxgTawQ zOr}r4K_qA5QsOe`scAnXi4p(h>-oJJhBI*3&=@s?C>SejcK3F%6ZL#xhHa!9otm0A zNf-e7e!zEL{WiY*hrb8YY{OwD^J|8_vPBeOgltRb2Bt$F*+yFN-!7e zKh`MCl#*$A$Wh2Nk;Ygn@epH5M6UuM0uzDEo(c;Aq}2N0okUDy1ADiIF~I5S2>ZKx zn6+~R5C$(p*)A}=mrgPyM6q10@%vx-V?2KR7=6EnnK^XJ6S#f>_72)2xN-d^ymuG| z0m@o~##*FgVnc+cX(4%x3~OzkGMFNejkP&=m4Z%*tcdQjbJ5nYi;~4onwtb+cvPa ziRD-{O&c+c(-4ecnI>(gvVg)o%prS@GF2l97)CN@1IvtliW9rM_wYWzg@9ogFrT&X z-sN|6T^IYx3e0{&mE5^?6X(vK$I$mUI(!809opGelz}p1O0+r4b`Hj%X`2j6+e&zP zKARyJ12+)X0TEaWYm4MSjQEi;Syh+_qOk^sjBpUkGe$L(n4*ye_}-)I*XX)6*4+x; zi@W6p@yP`|i(Nz5pEa1b4GaQ&cZ~JvL%3nskmkpSk711{kPty^8XKix12P7dBwuni zo1tl2v~4Sd6gz!uYpZlF0nRy5bsS*4g>xQ65f?)U#vphv1Y!sXUY5{I#A6J|HfW8F zI5>Fkq+HH>9LQpG>_n|GXFJL}xu1B3;Ck%s?qYv$564R zvswWMSTbl$12fwJ2bt2(%^W^>QBP5{uloUi>0>YB;NgAvVGVYJ)X%mcqp}a!frHpw z!Fy4ejm2!%qHX7qn1zEulC>5B!^pzwk^5u#k5hYOvXTeDCcs#hdl&%cv25klP-W>S zTaS~}rXtC-Th0iX01L$@+mFHC!5YGqXRhGp&Fe6bIjhb&nJqu*u6mDi=gwi)&T@w> z7BUMw&CN8%fQ_I4p^^ z9ChMkx*c=59so9C^_#3Wxr*6mZphC?do_wsKnz!FEZ7(j8F)|8O82q60Pi{=3?L4e zw**fX%b`aI9_-8d7+u$)@79>lXV}@EW4&66D0U7U0&HXB6iqQg8PRY|+CB*iN~+UD zl|bsZDFmNf%7|M^Cn&}`mDor^BV*ykw(ugsfwY=H18NO81duc0idsfv2&?r9>}7(H z_acE!vn4jn)G`RG^$K0L!s*E|{_+oh3T<-^ckkTEuAqo78|?uRqb7E|kN1dpBAW4=0?~3moqc8wj*hat~f^Y*FdC#Sb=druH zgVWW3<>?YG0LC_ensDR;aQE(A93LO!x#yn4x$_sWUM=99L*FxuGq5%p<_2Ue$k?K8 zrF+1zU@~!m785(T5JgrKobogiGezDb0!+a#tvy(Kd~66$de9qE(Dc z@hZJ^^gB_uinNG#lP&4oo%t^lfpx?ve-;AD;yV&Z@NuNnIe0(7yFrNASeT|oJKw?f z?m4{p^2acr&*43z9|l>*z&m)A?AUcHcsF3VIK|_~2LKUv_x91mAd+;RR#Az-Q>#f= zHFsXZWgOMamLu!O)QLzb8%e^PzHzAs2QWJXKVaxqa6>0;;{a>KFy7hO!6!fYNz7(e zRxR`cd@uWe*{1BzQL@BC4A2;hlapiIzjqINyL-6u%$2Bv#i!ebqHrS zkh0Cf3R^&{768!zH<=hp1{bm&0p;S0Bu=htB8EbM>6IaPcsF3ZT;TNN2t(h&`vKNA zunku!*B*Q2I+z zY$~T_0M(HkCpIa{M*@Pz4`l+%dOM5Ike{!EW~mAiB%06Xn9W+u+6GPAV!pGFkALD5 z*xA{Eu}KXyFt%`t5WkPhFo_iH)@uNSW;O?rjRB^t=W;zvH1f!p(!04{l-o+tE-6vs zq&~+s$0k-uRFIaWl%z}r1BigeHki%kuuTJs2JqtK7%#o}0`~UyVN3(t?tt0Axrt>E z+%SMBTJmj+M+XmZ;lg<|?F`%7+hAs#cI)_J0|bV(vm6->!Nb}Xvfvj-o5LbyTi3>8 zi0VQ-nq-{i)A~yq+g2*G2|z5;kODnrH_Y@j`~dfk9X;79eBceLyqYMKj-l zX=VVv9SOt11#!&{#1lJrUJSyg_T*eriJ273SY*z>lscvJvl5k7; zn`vOvT9f2rCj8W3rjkFBiIl`;HZiu6#E)|xeb>Pc9gdHVVVgN@I|EE3ZJL^*=v=QV zZ_sxg9^AW!E6+TO{r!D-@3A0?1$fEwVy{%8iBdlKOIp{aX@7F9(hgY<7uPi861W0H zP%$#PYa6sg2yQ^|9?tu$rWoQri#r*xUa!!!a}YbsW-V;HEq;*Qf)5Pe)w>Ufu`PkA zq3^IbJ;tnUu)RG85zEj)NNp!Y*lM9U-Tb8fz?n&O^Xuhm>S+s55`!bQ6xA#l0|Qb9 zt+>$I^K(w@D28~R48s1obLd^b&fY%kqXVqhCmV~(l_`p@2OA4xni!)9I6gW=2p&K1 zi66q{E6?EM_z-=!Mqn@d19=Cqhc)xKgkh2dl-Jt=$BEa0tDPEC6#@q!P+a*YiC|8w zD1%y&V4JoTw-hYi1PH-e^y?+yk-Y%#!~h)zhhcD-&0B15?_zg%4?8=1u(pB3A{^j- z37AQV;sS;MA3XZL$H~btjIns``RA~+vxA2ZA7U7SisZ_&h{1dK9K<8Q*eF3|%`!tJ z9wCv=6kiY$q4;cwLk6vp*pDS-TYy;>ikI-ZsKGuGcHj5#A*w^~;JnK*3Kz$cwzqe2 z;o@anzWglaTRX7rHn2PaS(_uuHDRPq3=ZB46%VGza=F00yLa&Xi!bBF7hk}wn>WP^ z;1X~V4?q?HF*$}25ST!aaIzSdA-Y`Rd2r-Rij#a{L{A%QU~Ma#t^_B>Ui7g;^auiT zj4yZ$!ywWj%9td&4H10saE|ck(PNyPE@K#9c4=nc!A2Ln5C*bJbuNa9322)cmdhpf z_x7>3w~y1)lfv>S-T`q+d=!Ea21zlOXs8AQmUOtHZlaw+kU>mgggEX(9NR4I5d%0N zPyq7~b2b@6-$$H^7nthdyu)JE<8(QILD=5jM(_hd@IqvUphdY_YRQ0I2lfN7wIkeM z40yG|VsVNG_wV7kXD;C*ANdI0efQnS1=5t@U2eQb3flr6lA3O0?It)sz_2U@8Hiy_ z6S=hk5#p497Pb}L7rcXU$ZJCleV^41N4q8jhKobziBLHNgurN9i+M13_xcTd{ncwC zX365@@IVNESOd*04m!zU*ds8(c@Od)frAV-k;V4*7TnO|!lf&)O@rm)1R!Ay6Fi7! zVh>o6mT3rp5G6>eHv@qQmXa}=xKrX7ySuxX&E^0YO*_MSxkP{mdk+qbzVG6SJh|3j z0K*{5Ml~X0vNMq)vVZ)_*YNgRZ(+WB30Izb365KwyYey~-aN)I4AX?s_dUX}g7+?3 z-_qg1d#qP0SleK=w~d|M9Xxa83LYIi%v8xN73Mhj6M}~c1P?F97&m2M5R#Es+qSUn z42C_Lc9z5IA$Y($1O|qo1A<5e&JwCNLOG5c0?2wcNi2{B2gY~ad>6;ZM>t*fXbhpX zGN?B=DYKnz7m*RWX&9W0yfUM)7R%KV>uv?x7Mge!nQ4%#+JBFTv9j$p9ji(V63|yDbkswtP(ZRwv@n|ZfpO>dZ5*F0 zKz)NnFH*|&9T+C21r`)kNPL05Tf;er-Q8Uf5qu=czVBhy>pY^jv$GAND|meHKst+& zJ2geky*EJcU<#Pc#j4)f*}~S=RvdbsiQ(q~6V<{HBs4AFgb`PgU8KK~p@kR;O@`cZ zwFa0m+u8w$(1KxO5>a5_(Zl<=d-E3NdzazZVgLCTvF|*F$B#j#1sS!XP8>-N-lOZK zK5iJWyR!{93|OsJ2(E{*qCT5;4(Eor{G&xPn_=)G`n?;#98(uy(X?V0%;wve&u3_x z2J`t0L$}OCFIz^Opf}Q4gJ(2ti`37-2jmn8)5P=Q4K*=92l(Xa1W_qsG`i@u1OeSz@GwYgtVP?l@EqrA1fRJXU<6ArY^4pxz}N;&JHybg zkd{G6uD?hQM&CI!O_L4quIn)jJ$84u(Zo;yK>~~|34y!b#Rc|~HzfAbFo@@wMqn*` zbRZ_DCw+K)czk$#cs%8i4dSoAaf9>3q9}rGO1xFS^`h!&@{pwFmFLoS3Mt-|Qr6O; z=Y}R%Hq8Z5fnSLyjOCk-bUyLmta{4aTVUwd_{ab4zk{#-;h$k^x53u_ISfOOfATMn z2mrqR`i;E9QBo_ZeVaI>BqM=x0|)JLWLr?jaGs3AIL=jt#rSDO|1=JLx5EGM8$W_K zzk3K{Iq&WM&%Y_vt9BolD6%wYbdLn#VkB<-9G7_B)RFbs=)p`B@rzoow~ChX#+N#} z%o8{3x-_$Gv@CpgWc`n*@EVi9bF~upbBvrI7iPhLeqT1ws#ZR5Tn{G<(64K)_RE&ChLINfwxdz1+ z8-gO2Jq1dBZGEl+GI0xI6*J9FP6jyNBd8?UamS@cQ}KzbN#T(8PODz8a7dk%aY*+k z9H?;glW-_)=8bEKIGz>%=m}&9$cwC|&FrbpMJ%x885a3e z`JHN4t^r)KpU)66n_~;*M+Ffl6*dO8h|sQE;N)b9!AnTN2EgitffMnLa43VNsVs#< z=AS4*a`n_Ih3a>7B`O?*i{rA|XNlSoK`00})J&S`vVj9j^tf9cV;DjV&~XfmaJkF!JSP{tPvjv$D zJl_7+|BDCj9bmokFdXtc)O4gA-LZny!c)Er5ik@EAx^wf=xyMjB#5H3+=jDpsHIwV z*()5hjkYOs6TzJxKgMfc`CqXf8Z_Gmp^M=H)nuhgYW1p!dErweAXMXM3KTA*iKIJ>H|jP%#ya-&=n)QXeF?`$$DkB9v7683j@2Sr)gi}p z(sIum5@XEMV;rECi7v!eDlknPIpRs0Y(F>-M+Z0X=)E_wUOQ}^zX;Pi(CI1M5RfdI zW?TpXWs%b)LUWfZF_kOBNHZH$y~{K^UH>GaEAcj#Qe%Fl3`*TmS|sPHZ2(ceGM1+g z@cP%k1m8Q%EMwJUa%se(R^zAgK9bSRNm9mY=xL0JC-PP)6XlZvVT=PQbP5vj)6}YT zg7ykAX$himV0f(EAwu85_XC>UXF=^Y!mt8x2w?1$WP&Khkr|1R#N~O``UZ#SdsQcs zuBf^%p3ln_MaroOqs)mdudGEY#??^YnI**iwcq|PaO?UZ*ojS(60P)(MB3J(>QfPi z!s1Cdlq{4nv1@xx!7;)?6G&CR3J28J)j7QyHDw$e-uccy!kxGN468+q;PL~6W(zPS zlWoH*sg^YWkmf?j_mSq!wn2P4r81wPDa)#ykvx*pCdK8c6{$#NJ!C!`5k=uR`82U- zHMayGusnVbt7Smv05Y<-d(I)6z@aKsv8Dd|L2xJxKEeT|y+zuo#S^H!EqQkd6BaCv48EPQ5~6O>#=2VIyp)n|K%sR>RwgPq zWY0g1O;90_4II=x8#s)`#FN^sI433y;^^LcI9L#;z&c^fc|Bs3s%VwwnYO1E@_cp+6isr=3z9L3Lu zN>J6Wx>Yewi7Uu?s>)9Po{U3LRc9ZQ#FC0t6*gqR&Pdy1da5O%jc^uWl&;lPS4#qo z>rbW4P`XiF#u%uK`!p7+F6D0)#yoL7)ouLnSUnJ7dwUC(;u2B@Xj|Dn^U3_TQK=^d zJ5iHJ#ZDV{=}y~dgKi1~oVMQ@m6HkLjEBc{B{G=L=fa#3P_lp>6`@l5P#Y>3SqKwg zjH_7lX!SUBbwE)KUBY zT0y;EGp8EN5<(GaIzwm{RV{pLH3D6Bf6xwaCchQV*OhfC2WEOWB^eTUg zq|l#bFi8_c1?Hym-?Rt5M<8lQHaU2C`T!wp;mQ?@`3%^aiSemJ;<__PwLN#b`km^3 zCJqXt{KS~?&T1(C`EbxSnr3qOY%%mLUVfoLV@uujI`#Qo-+ucxr-r1_!YcHtC_YW7 zN#9GykHhMuAKmy}si>)jpP{|##$)70oqcBr0Y{H+pq)-Qs@rlkS2e@GcfN}rk zAK?G~hyN+=-Z{qq_AdqkA`bE88w^4qTFuiDHVF@(Zg5mmiySRTEaqqDmc} z=fdWumhAB-9U*vdkXic2$B*E=!;k*(rwIUFyLvmW@}Sg##z9y4FAN6_?xqUSgr$iu z1;RMCxitIfELmCJLZ(_ie0Uemt?-jS_HzV`#!9q1r8z*U(;A$3J#|=G+EGiI)JdtV z(y5#EPUWYis!cqX+_kg}B9PWSkLz;fcTgU}f0A~waqb5I%;x9eR%-4l<)&>&LQ>bE z2&bskw8)%1km_I*F{gH(<%MVolD5Urb4pWrsZJqgh=q{mumEXdId76mb;x2b4Zw`u z{b#24T_x2x{d+7%iGao$v{pJH<#*~yv12Ytq3paDI!b3Ff}{=KaT%NTDajgMAT(B1 z1y0vPko-k?FEBNVnPN5GdkjO5)03kDR%-JYkA#87!4TR;CUYlZ7*7t??MV3&Qv;I( z%KO7JWDoh$cFJ|8>KPyfZh3@*4<(XW;efG3jca|tqwmTt3?qq=8Y9s`tF~xjl%$-- zq%j7Hl`e0h2!Q5_oT_Fb*Q1n~gk;ixxzbq-0Ck92iJ+)DQa_{(WmQ*{zgc{g0F`qd zi^UQLkBvd*q!43#v3vOgj)%)rpCaQD``xc}f$ zZu{KI?6rWz93_i8Em)gu3t2u}NqlLI~=m1(;$KN_os> z;JG?fV>q`8%Fu}K&99bte_oFwO9XXG!KV7`haOk2zKv_w-horez^x?-fCYv%l%-}W zizv?UBFg!$DR2a4q;)9(D(#PT%o2?Pm<>W05re`bNr=q}6^fw}RRyW1Q zZjO&=a_4L-Bn~W`p~A|EQ{x{Ke}%|#%Tz-`#ZmjH{N^;AT^d40XHbXr@DB5 z1`eYhND?%Z#?rh$#}p2^3u66{)w2af8ez(qN?t`JZC zONrkMmP(UX!?~m$O!drSpW~8<%ow^3w{G3VTW`IM)haH9m_aQBmibdDe35!;HhH|C zbRD2@D79A|$0cDNHRF`G794mI2d=7??#wDRo?=!LzNnToUAMx`n>X;C*S>>CkB_lh z6kDsZF|IxGT)*)xc;FVEqyZ@ zZLHKDveZjU5&!@Pk2l_U1MAMgvsJ{IbaPSxs-E$kmdY0Ad2PPHlJlACq^dI!RGGv9 z$Wn9zhip)kW6T7JDD>#7#-Xf%R&ZFadtAGDfOp^c7S>%iy#^P!Y#)_1m9l(6X&y;D zC$ykg6M0@jm;gf`J`z*554dp6hDfcj*(^70V<{le=*c90756dF^&L)6kI{8&1e&33 zTV3L&HLu@0#v$It%y~*;tzyr{!e^#Rld%|6DzR*feRQ`BPQhjTMLw>FhL(Xi=IPgUK6HcYvaoeJ% zOZumNccs37#u82*-v)(45O2ZK1Yv{_B)_o67Geh=8(KsFMO#EqrAsfXv7skiixnj$ z^0XS|&2!bG{N&$fos$a=mvw#;H|@tZLIA&dje)P5yt7vj>y;_xI2HX}~b z(-p_MRobFVs7wKh8sfW>XxorXbsIJs7zI5hER!c5>`$@f-ow`F8nE`2xX&y6qqL`R znHn2O?aCd01`b-QO)1JM_fAQok%Z2-bM-W!%+e2r1B3h_X8kSL)4M<)Dtt?7UnI{2 zc7C|n=6RmP25GW>YI8(2lYMT~bawa?n=vI3rb>8%lu5_p_$FHNm=U9|Ryi&>$amEe zEh$hG$rQ_uqcme_sRc=GQM`ZU&&FXQ{c&t^jgDdc5W7tW8$uOM2ek8%U9lp<9 ziNOkO-KyKvGx{E6Ia|WQ3G^aH%Ye^FM^|*G?@4v#>iI12D$sQKa^|_0e+vKZum64Q z%ne)*tk;UfxQy85XLTK?a9~teOx=_5tm?1$J+%j#IJvs?WE>)v8p-_5bh+RUFrIz+ z$MGwF`!{iE-zZ`q69#}J8q;=M`5)_Qj(RTFn4!8SU)OR-^^NMd{AP8g24&;sGmdFN zIuiE)Y|~)>;>&pX#q(eWd|5)fNX&7A`Rvn~n-W26;DBiyCXP)nW?Dq>xFU6nH;i2* zsjX}kYa3j+^27M}$1lrZq{{hJdp@avDvjq|MsI=Y;K}=SR8gPFHYw$guH@$EV(WOo zI41i>9MqPACnam5RqP1S+5TUnOpos=)~0 zbS8HOVtG>y+XRW?yR_GoUR(X1D9zXgzw|f%HqP(QM)?kae^*>Dg8{q(r zLq!}@IH+Pu>+9m5h(qa{GjSk7JKw>lfA&}L{7WxayD;S2uU&g*?4oC&GNCIX(T&WF zMxo$w1qjGnPUdoQV^yQ@zJf?qo$H^#8IqT?I0qMLGjt0)xbsaM-2Eng`Pcp~0l?L3 z?@U!cx4*(6V)MP?kOE@gD-M!@1z8$0SMux!r*Htv3dzgGBiz6BEqwlq{~-aRz1_13 z5aI&1L|Jtv4pPmi2uSW>1y+P7-KNE_YCLqg*+1NDndD9^V2r`KTVs1?2gBfSet*C0 zQ3{}1wI}QJ_+8Tx0j6ni{_@AMwQ~u;-yMNCs++1-&F!(h>RC#$kTcX#xp4vqdNK~) zdmJC1f`iAy+qbcQ z{KRKIh5h~g9K0MY$x?k}!jJVcc7|rQhi1N?-<6hpN?f04AmJdGPjoIP^BdVd;f%}2ny7~q#uNL_JfA4R}qVB5fwDKLmH8Al<0I*!GaCCG8@;%te zeqrfwUP(@aY>-!;LBt`wH`%V0q@jpag#)4?do~V<mE6;({sRGIgXN&V1hHz2tWq3+`5x>sW$rDY%y2!5Qdb7;x+6P29e77elv3 zV_0@8n!rJ*pLNT!`VMjZEhrpHd*Z#d#+k+eCDSXmqH5C^2eKA>FT8}~lNEM+hjSM% zVQ+V3y#Qg`4@v)Ep06y{YkKpm+$H=LJN@ARa z!~OdY@Z~Rk8E?LQ4X3ATtb2#klOuH9M9ydC65_Zs5$6o8#LWqav;<-rGN#n{Frs!Y zzms0p26H7bNWH66MGP`yarx3k{OaHM>$rRW058oA!h3hH^P`_Z)3#Z4BngoSrErKy zif?@LTe$l6Ros8P!fMrFwOS&$9(sXGy?PJ_in|LT6GW;f6A&mS+k{f@oL{2aS0(#! zTF4E0v_FGDoTd zJJ)aGoonx)>xaD6mRBpMC>x`UDkjNL2XxALj{lOEnC>wsoKryJ((!5?P)!(#@TyPs zJVlHyT|AGA7tYDWk9;IQkhZj_08u~yz-ry$?W@=E-8bHb^MD(Av~g=GQX>NiheQZf zJ5ewRl$H@llB5L1xVA2%E%7usaC}dFJ+(jmK7amvP7h!p?(qhhPm`HKON-(kg%EIZ zy2PE^cd=NU#)V&H(_Womr3iy_!}2bMibM2oAq+;y0U2pjs9dnb@kuYo#%gL%I!5O@ zl;=5jQbMS*DRN`g5ZMNY$en`^czEy_eb?vxRLSh3EJGX1VrfgNnhrIQ`ezW6i=(xW z?#(GTTzWEBAIfgaYoV5dAm>w(4&{exrs1S=RN2zrO+!;_D`rO5_jvoQH?dwXWgj`R zFviAhH%snoCX9oJ53yRwOeEVhc^@r9vM~)A0C7)HV`N8S*~m>cQ6VsFx@L^*LTt+B z_M|#Bg>Vxkr`c@sJmS0)u|mA}NIMadu_8f?$-5t?jq@ZqIUr||gaBh3IPbBuw~ysw zf#bsibn6qi!ATk%S@fn}0rvVk*WjFkbFy1g)3nj9lFfT;oQG*5XP1m|z!+PG5n?pb zSeqvoCFy64iRX>%)J!PYKz$cV$pQKf1@Y}SamPi5Y&PUVomiBlgdu4^NVY>IGU&QB z=G)u2bL%GFy>=A`5ALJud)a@^iqv&eIiC;i+(h4ZI68VP+bbC>+gUa(I550-(s(jx z8Y{c=Gz|<%iWouMsm5CQY#YpGGubw}k!>c4jO_Cjw|o?UW%F1|kX@B%bGCh=_6So3 zhgmv|A;jxL+IKx!d4#}(u6J=WI0r)9){hzB0l^2bQYE+Fy&Bzi1J6kk#3==a!0cfN zviqVTSYt7p&tOg3{1&odUL+Q4Ok|=4);2Klw=soyY-2NVrC19QWGCswP#m)TB73Dt z4n?sG2+Z1OmE9&?+~=MF(4oa@xx~rIY210zfyrX%Wq(>mVUkQ3*WUUrE?m3<*AIAj z?=~2WcD{qAZDA0%b&M6Z*2diqXK1ZK+ZZ&~qHP!R3ilX-v9iN))5nusF=MnCj8JUqnq_7-+_ z_Yqb-5F88zFalIY8}7e%6YXpZ48nTd!+VEzCOcA7%!E%JYa!g(7z1mJY~9(mm~CyN zX=g}2vjHNb%+Xlc3JhS_Mwm+^FF+&-*|O`i1sM_ZLR2OyDlY^c*FhJl8z(fe>?rAd zkWSBAPsF|8gNN&S*~OU{!O3=r7syU9t z9)=w#&Xx{7AQ(n41|9&KxH)fNct3#s0J2TyBonum7Ks<4I*Fn36c}QL>;_Fyb#PWs zAc8ioqF52aaI%+~3^H*P^Mq%fxq^d(L$pJH8@%jfXe=7maSVcwOYu|aWkb8T0d5L< zB~?cV5redc8G*ckcMh_FU7DSr_HiAurzUD4?a5jO7n2u8WXk#+9KfF8DP&1v$)IUW zRv)tSVgRVgczW+a-bvjYNA*g$c=;0U-o7sCIWSr9(M*xWPrqAw!e9!wRnN&~ER? z4lV-NdyfkjFJj)dI6XNAjEy4O%kI!bU^3Bw^s>w%5kMAf3|wIN^%~UoXxkQT+sd*F zUS^c1oYJOgU>X~V*&&3oPbQ#L9Cw>wi{n*EG^Tu^BPT+4$;pF%Tv&)yUySInMVu_(!OMJtKluI3G z4T0FnvJfrMB!WT7Z;}1w8MC$pjM!Kr%{bSU$i{We>!AZc+8!O~s^dzUo~Q|9nphT) zJUM2@U<^7yw(yRsaBf333jo<)mzago$AodZTw&%M-hBOaeD)`P2H*G5kKyVY-^I{( z2;R%K(z)SWdm1E`v$bNmC%tK{$wpSPAxIfr*@BM*QTb=N@yg~Vg3e&_E_owP_L_$P zGoQ~en>AQ27|!L=u(!L1z5R1IJ~@W#df5Yh=;BV(a;%mMtk-Lto}A#LANv@#w&plF zIf8Qz#x|Mojj@=`T3AD3GeyJCHa2=4k^70zSmmw3qOqb%$;6;w6PZo>!+@If9F&_2 zg-`5@_(Rs$5RWH$me$V12J;L;58{9e=k_q0&t&k>$Ogm2TrCZp&2i^Foa=G$@FBkY zo!79xe-6*T^fH>Jh4(&M{G)+nqU;@%$nIEbC>K>3nmYTdxUZQ6su)BfL>V&)QKcm} zRb14-lDHDQ>~bu7vd8^MXLGboi;L%Wu(y8>^PO`bW1|slVcM+%NR$Rc-{bC`JCJDO zd2DTMV?LXS2gV*0=Y6I6bE;{?39-J$7_kYWcb1r@iT{ihZ*2pMak~{ERJg7L6FVjNEH0CGkX1(fuA)|%SduGRU9L#1 zfFU#@oD|p~FlFNC`yO2vSJRKn4mt0zHJ{-#pZzSDNw(*99T0kuQY9vOJj{%Ky~d-5 z53sepi%ZWui}`#m+)qNF$(5CH6E16nMI)GFsGV#J*cpszgq!2PBwbB3Hl;h#8aZR7 zQht>HCugrD`tV5U1jEo{y;@;$dV=+80q+OcrbWB8gG-k$;otb;7p2dmK`fG2<-X>Z zCIc!2EKg5x@Zdg9j*qajvy1bWE&=S}T`wG}3Sy++h|?Ae^_Iw66e&3xsGFE8kfn%@ z>SbsMnJddf)%b}2_1`Ppl5j}q!j%qH74;27Oy8N zULu3}Y!1`3Vsj9Q!9@mL-(!egO1C2A zBkP6_eTTt0tk+9$2sk}Fgl$@wW(!Q>ap{0!wTmJFG2HCXtyj2v>jpmh@lW8w#Y^bd zE4X0|=RMeyL<~*N%7|ecj-oxP5e`vMFqo|ng|Zzu#N^WCi4c%QDHSe@A)I3H8baI1 zKI$aP`@p$D>dq<170^ZZdASlW&hPKy^!OOF*$my_F?1pUjV+UTZPIy=`VT{od*;9M)ya!Sj^`wE?&HdrfIP?vp9UbgmWEyNaQC+ z*=1V7-+u5|o(8aYc=`Lk9~UlOz`bq-@0<+Dn-r7_g(x^gBRqh;2Q@+%2o`Jz%re#! zLXhZdY;O)}N7sHTD_`OhO$oEB@hBfW0EaLP;&FKoHw>95Ko-q>7khi>aQ?z2%;s}| zJ=)p61TVuxqD~l_%2uJ7>Z;2|ztyomGXFW}L^0lfFH z#)7TQR)j=Nqgx&WW8PadR*XVRHhJCv;?fBmS4cEQ4B0%sVj~WbL9z`ew47ojM6$J) zk97`t?NceUZCiLZV6j->@!=5;9zDX9XRct@HfY)zYz(FaOxPGeT-FaJTGc?`_gJl# zI6iue2lwva%Cpbn@^dfZ@x%L~QlddgQ59r$n`+KMn8#Z2IMk>qkb@tSvmJZG%gaJdhLbndNe8Dt{=wj><0hSmr76ihLTX*s0ul$L)Ze-D|PGe*=WUvbc zp^Og0qG{qlUGP{gB-`uKm1l5?3=R+OMX??2iO4L08>I9W-#Zu>7~5cGIWm(<=i?H9 zvoZc)t(7%AW;2)=arV&*^oamcq*qCsUB%b_^cy%mU0`=_AMMs2 z+O0hqPz!0M|A;V{3EnvwumUi+0UQEmvjOYn5=TdmasK>1_Rd{^_a29j9!Ty&beGZ+ z3fb3YjuJyC*BFbYorxV2gJ1KR#7LTEhLi!}y^mpCJGO6UrcC zF>klwe8AVf{whvSPjGs23~L*7-BQMal3kw6(9FKum>xkAm2Y5GN7zlmeVK$#* zZ~r{H^(uEZA!MSan1Zlcb#QLLEPFH(2yB}shRo;E`OGnAa8`qcBHlQwRZlKj2%hzK(|v4{&sJh@I0VuDq}<61DHZ)Ku3$zPRtp)bCDUPRdmDS_FXHrc0j3rdrH!>^1EnY-5dzT8wy?c>4!gVCu!hhL9`jiv zj$zk{;p99z?<$ikxvymgp~+Gr+w&xYS|J2Uxgo%q7ELolyS0tQVu8~|hh@jG?Ho-z zL$_XvrvV!skisclx0WCl5!lQCYOvF^V2J4M<62b&#?Ib8Y};bJIF4LzBezPFw`nYz zhA<2Rm}ShVZCh-$jkvx|3u_yYu?R76=e>gm!x6zXZJhMwBco+Dj*J-H^&Aq7HeN(W za!4#X@30;M4iArTbaa#%(AWm-d$?gG&Vq^ns{ogTdsF^jTANzV3H5T+y#O$qOSCY+ zWLqHBTnAhLWP8CxfRhSJA!9ubkUX|J7b6mx(yv7!QI1EX1z0J*vuS5=&WU6*HhN5m z!`W&0yOu;HI8`FLIjJ`IJd7QcSRN460ito5-!Qn`sRSxh3)6Ecnw!6;1}4|EcsfZM zBf6%AMG|pjuF6;t8F&WPU7wA5*>~1tH5vSXpr%oLczk$#czk#~{qg?;#!tyRU0<-t P00000NkvXXu0mjfIhL3Z diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-semantic_segmentation.png old mode 100755 new mode 100644 diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png old mode 100755 new mode 100644 index 784b4b983f7de30839bb0b102ac28d971f50686d..d2dba4b30b07bb97e58d9cecaf23fc913f74cb19 GIT binary patch literal 1647 zcmZWqdpOf;9RJZ)Sh`^@?YOl%c;=W(g<2?=&HWn2(Hvqd9*ZQ)Xzs;ih0{qyNQox5 z#E!W%X(7}>vtvwoy2#AqzH)x@^qkut@B4k<=ktC)pZEKHzTfxFbal3qms67i06-px zwRQ(>+QyKP2Iqpkr#S$y^$^b5$}_HT=2mSa@1$bdy2Ir%>z=cdUAuQ3CK~(T2uLSD z_SwrPeoJR`UzQ}Km|ALR`Q>$Ei%Tt&=<1K4bFWHl-RhIF&GU6WxHoNyuw`0TlunIB zr_4J&BB)Mx_B-Tiztz|UDN#91Nn*q_ir!9-_}j74*xfE|BkGlk#`*}JM_%w9I@|o8Vm~0b2;{+)DywhR4Y*Adl@y%LSFMK-g4kXpH_a`(XIHP*&5*T0R*UzBmstRraj@LSG%6Y?DK z%$%~<;i-W{%4M4y^#WD`hALTp(?IGC8CukYk=to=dSOjG?}q^qCTa`u{6*B;8;t}6 zt^r>?{IO`dxrRi^7d!fi=v{?D-&RrCW&FtWz+WSMg@&E|KS)jaL}gD21eT;3(;Yy0 zTma{Jtw2o{7+=aZ6wdjOBEC7HkIB@mFOaymeeIIx!+PqWLa$PVEcxr5;@FI=Fc>FS zl0dsA#em3ml4ezh;8|e!U30o$F;CHrnCH@terT$JR8$l&Mw%u#lGnf^Va&`Gqwe=E}9L)X2;vwnwv+7Cih?8 zU--EHo{ek>o;gOw#EAs&dT;iIL`Ota4-g+zSC8HbefUbRr;gcbA3x8n=gllEETFfO zz`UQFPZ*zX&1w5&w6dLDs11cn`W(U)AXb74>fJ{?3#D1Gt@XSk|GgaQ3lj8@sK{{ z^B#jYHA3)LGm!hZ6@nX{2k64^hSf~Y69_25D%id?S?BT@ms%ZAU;Dz+=O2Ke7eo`a zY+D>7sx_yIO*@4IwkBI4ihoY=uqS6hR^a~o3^`ro2MzBi-L6GYE|#8?lis=Wl(4t) zV8E0wGYhN|&C8_&#IgowM#_8n%8fFYii(NpiXl514be!*FVsX+LWDV87kB^Fjb zLi0rehYlTzjg3XuCXkOF)CD^jx2k2m%2pAIB+8Xgc;UsOV+r9|I7Tp9CD_HB9`q$P zqj|X!dKWu*P`$g>@Bm9aeZKm|A~Bk&2B#5}Leg_o2gN6Lgso_$*GsORz*C7@`W91H z^EJ+5xjXgC-lik|4L|z_Y_kDZ4dj4bYwVLvEqu*fP6A1i`SO*<6Y*O~SmLH5ve_C7L z0(6IhcemSk*W^~oDsF^!0|t$XuvG+mH)SpHumPB&%+1Jj!1LcD`;WG()#lZ{Yis=- d8I6#)4tQ8frP9StiQq>9;B1_&YmO6We*r=yAJG5+ literal 1625 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 literal 1625 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 O;2a{mIX2h_GyVbd8s5?X diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-depth.png index f1cd28ce2e9eb855b55fda893a6f93950b014982..044fa4e95faac1024bd37ef5195a86e8d8bc7322 100644 GIT binary patch delta 1040 zcmaFMyM$wcay|2FPZ!6KiaBrR`cK+qAadmK0_pN~HjUw*X3abCi79O7g&?y7+Z+5P zITVG2?!A6_KZ#A|-=2AD{Jqn1c@&SF&3ehCHYqi-daFj)QZ3Gds~)8k#YF~) zv~*u?Nm$CWG9Z)F49NYZbMRf1+hWxZ7laDos2<3AvDxM2l6>}duG(n_7GIjQNQ2=R zR~r|1`}g0gkGln~Uv~TLwYa-)%dVZ3@mg1ZJ-fCxaYcrk=kL@f;Y- zqSE#+6?XAecJrNoXN7+}s`1gjmc!1ydT-pdzvF4ZK8iCqu%R`sp0x~8(8gIkim)ZSGKu9KP;dU}d{AyNP*oI20(5@?wE%Zosl^Udd~)CUfq{XO!PC{xWt~$( F69B;#@zekS delta 1758 zcmV<41|j*R2<;7!BYy_8NklJ5TdG6vy2w>Ht%R!i12R5ECB*iIFdf1V{+J z1rib?0xB^85@J9I3M9=txNjaeu^q?PWB2?92(6Qxe@>2X2|k1%2qLEw+JGv(UQc7M zJkPCL5{hnU1CT1PT%s2ui2=(cqWUM#^OUM4IpEq7q7^GCIe!dRlu%YGCh9KpOvEuh)17n3xJZN<>sNyNi;3zfTA$ih}zs_q^Kvh$BiwQ0tw> z4RwZ`dc>HU6st=pd7iuQO2rwX5OGA@E+Ot4_E!F|a(_VV1P3R}vVdntmw*FZVhvlZ z8oGpoN?*$z=zkI+S(as4*6GNGZe`~}`!P?wdlG$*%LQ4MwF;D0{%GtXqDz|TbULE0 zu*exEUGrEcR6OH!_Om>A+!HE3kxz3G+rw3Ki7MXa|IsDQcpl7SCm0;)5^$hPz=19a zOuc)J&AS~$qkWC8f$u2k_xoWd_2A?6>XgnXH^M$Ff`5rWm!usW7Vj-j6<6B+u*>2~ z6-7}L#e6<5nkWmG+m)Y$HyyOKwN*d&^Yb&^pwA>KKPl|Ch;Fyr?RGt=;8H$N(N-}U zjg}5be;pqma}8q664}~lIxTQ@b;W*ocz7t%R{ygiyP}*Ba&mIwM}_=}mY#~Uvorc9 zokm-HcYknjATq9QtHgSZSDqvyCGskNUSD66m{r`|+>pxT{Kv-!*A$U)uHQBjDXf3a zeiE;Wot>SBhX=a7aLH^oGql@8USn+_FIIjYwTi8+t*56aQo7{#_g8Wh*LRZ&DuUBc zv2Y3de5RG@c6BP=XrjVtsNlGyPAvCV8Y&hpnSamcjYUIAqB81d|NQ){d=j0Cx&*=w z73(_yp>EaJ*O#PZ>?&GBiTcU(P^^N#zrRIarJ-UeN=z_3g5>ozZ#I}8K~cqQHj{j7 z%_W9dF(3^UoC(_C!X(H?9xRYm$kB|FP*V|d(og}V;;JCBXD}H2{r$DwD-?S6Xg*nj zd4GSk1*VFBE&+9mX&V!TihjQ@c`)3S?;ICts8FrA!m71Zk42#ZX!BlyHBYjtOd2Zi z1ypc2e3wUb&-&?2*}}gHC70N(5cH15UILXBv22~!?$K0K#L<=zH4D>Ffo%ymB6}~? zc`PCFt95*;9>o9h4j z4SWrGrvM$7^m;uYE6n>R{ODc%(sN6u7f<0UF1bq-*OpJoF+eLWmQ}04pu1!R|6v}w zOIC37w{rI&KyU?AD2UofbV&f@9Ts$nS5$hB;hXoAh7@uXd-LI4LRP~o>Z2je7@F5V7ttI;kLmy}c@h8-%F?up1Z8dDD-DlQ4!|HGgwivdsNt38s`oxs^e1C4}u%$(5kwz+)n>$=J^a?&NX#snoC36RC&}f5g ziDl%IJu0JGbV)OcA<_u-N26C#aEa5miZl++b({LEPYN!9PpertbV&pLr!@ogxVUf2 z0nfmc=ec(ESof&>Xc#4`w2hTAUrk&7J*O*t<;US>{2_$AzP=KzEI(^&Yk%~5c~CK# zOj=#v_xE?MiuLvNdf}zfyXX8kmzS4xgU$7$o#p5B^puM6qvGP?LSC}u=;%nI;{5!) zQhsUlet&;2GOli`#CpY0-P_xv8`J63N|qmr9~Jxi`?t5Zk^&{u>9lTVbLuAgYj<~- zYl_G?*DoZr&GK`1cSroHpnsQJ!uIxdql(AJNA~ljD2X~hPLM1=lgZ@e<%Rpz=H@0L zpCy1Zm{Q@vNfT?ba8l8IQ-3m5eJY#^dqZ+gqg!X8CDdn1xRLvOTCQ%M6!M z!7M*^P+XO&u3}?jgC@Ewi8ad)H$RR~6>LJ7(P-3oYEiDDI+*3h0)HxJ6qTuK!7M)u zOjNMvwYDCY`DHacf|dL@K2;2dL)yU`4u|DlZF3cI{h)rtY0RSbti7Y0O@@*q#9Rdt2#KRDAuF@sJc+R_NuJ353oEfKKimchqa-BUdJMDt zEP$f|v;0`bTm{f+M=mSU>56RHQBE2v5Cm!BKYU`x3YK$G!~g&Q07*qoM6N<$f|V>% AQUCw| diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgb.png index 9f51ae17974c79e721b22684ae810571980019f1..2f17d09126c575b4613c7d6dc49b6e487ea8ee84 100644 GIT binary patch literal 1629 zcmZuydpOf;9G}aMUnFv7Bx0ypQ7B{&Wil-}Eo)A@C<;ln@o>aY)`%lDZMoD*ZgVSV zJCbb@5k=@Q)W|tS&T5kTVU*4`~AMpo4R+;PHjyiO$Y>{ zy$k0-0A;cgfGW6VuMTd8K)$ow<$)o_tY?)nSx8=CS*mW~y(gm`KSlfYn(A!ccsaw(aq2|W+{xYXFRA#u0dw$JnFPQm0{j@4Mu!pb=4*)xHaDnvUWq^Ck0xEOLj@yA763(q+rqGWTZ^e&F$iH$%v|WUWR{5V=H1r}l_wqvWg*DeINCK7={< z+;E?7W=2v%#Sxpbwj9^E6@Zi?|5b`sY~2mE!0O~ zI&O-+lt9+eYLs*31GQ7_6Z5Y(jxXxRWdz!SObt+o%G})4oRz|`MRrK5v0S|&Eo=ZH z(Yu?c`iLZ9t21FK`0{0&X)Bi+X8o!i7GLTN4RP)+4pE)xr#^IfAt}lF6c4t{dv=z$ zaJtQ637JSE`Xo4z`Ovp&2F%^^!}to#6*j=mi0Kd6o$tc@N0#Ch(+eM~lQu8szKo$H zL=HXLhxL&$(#KDMx3%2gI-Ynn`1X}xMpiJnB-WLccZ76eB_s1s)F#te>f?0(vS$ZS z4_<7}8_tXK=R@n%vNPy3Cs{ov2{cO+_8D23U;@g$bsOJ`{ z!9YIuML4O~w|>B>8HYNP|CmCSP}g?WekhRk_lpE?ZWuXSD2dtl8l325%o;&T6KAf2 zV!#q-$>=Ru_)L}yvm~@O+SfOG?TC>_=Op-|1R!B$_YMG&ABr0{q6A^=xoKhX-J<6H zR^hqG89{YLANV*So;;47jobWztE2#ogPy!rD0L3u7vE8d5)R`o)>xgZv=)mTZ+Ua#B!AE_)_MpJ0HI!g>((VXgG{H>Xq-C*-82k}+oR!x7|d}h z$t1jDY%C^vb;au6OQ7sUXIo`1L+qH0Iv{5pgq_WCd!5`COS1JQmfbbKKt0V({1@G< zZ|h&je9@Drr@KMuo&A(U0ClDBGlx=s*1xuYj{Cx+JRi&@fKuuEQAhlq35pfs_b!^^ z_eH88pr7zQJ-i+CDovIe-$oJeU;Eel|K}-bEn7a8;)9Af*9wP^ax?(H4ahF+9uI+g G0P7$4gc61T literal 5501 zcmV-@6@u!CP) zdyrgJoyWiD+9u%R?#g0fr!p>k2MaqQaJ-KmrTJ zW2q%(cNMI~qSP&Fm0EXQcEz={%9Yi{)C!brp@>*SFvO5~BuvkA_kErB{&DW~bRHyR zl9}$C?N3c5-S>8%IcL82ci!js1A6r6acGGBuU4ydHXkiky}2qBD1qzXGatYM--R|% z3I-s60RUWf`JcJ2>o|_myXepIVf0B)zVu|FrBh@E?pF>r~kkM48J z>T%(FKiF(GT-Pg?%bfF0VZ+9$sVPE` zramD=DGi`J{cjDY!`VKzbX{8lU}R+U;)~xuIyyQqFd(H;N->s=jg4*JzCB4|t#ufN zgwTP30RYSJWVwWY*#>l3Dds_!wj}_@$HxE+509)`wffw1&ke({(P&IgPDW94a&k|p zRN|ZyLIG6CuT#I%**(^Dqa>DziE#kK!z1k%2_cLz&Y9yl05nC}M_B4i4&OL;-g)h+n7VCjjz{*ruVcudl-LTV}SaVskAO04P28-ENnpoyp0G*6WdxQ7NT1*jq=qkad@> z-*3JQBURYPyf4HDLfmF~2QX=I@7%n`!`uNZHQRU+w|HQIo%ZxFcbFH zz_>05d`d}{Wm;>kwJ~|E8fWy-QHFqiY*C%vB??^_#+-euDa?I>h}*f zQ&0jzAQdtWCcp%g#!S4=hc|54I5;>cr8Gu!E{rk8

GnX{6V0{|hjs?5H%6kd{r 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
^?DLWLdU*_p7Z$;yA9? z8;;{z@qFKJcO-Z`NU~^0e%7x)D`Efp+uc|`1gSuPgwKV4xBH%if&uIyv2wX=_ntlU z&{x{iu}83^6r6J#chs07yW2dFQmM3XiRaU60Dy}wx~P$0&kSCD4O0!&BGlu&;;P3; z7^DIhFa}{5x~^;cxZAgH-!ILXnHk2I5TdnM*G>nlWb@{~v@fzOZC$+o{x8lu!B+Qi z;i%PW)oOLimcL&6W@rP|z>;#_*D^rH^DPZrf&fgwddqcPmk{#EBj4CBwOzY*o_F5) z_H||In?VU)n+_eYk|&@1S(c^kdK@LKgO>}6wH3xO6JaL8SZJ zA}pa2z;!?gzV!sq5i9Y}xboR&pS7=F(5JS3=AGbTuDzdf>6T`Sj3Z7_kCF0xKZDZw z=8gU`&U*Jo-}lGH#$Hbk0BbgGnW*8FNleaQ_Y@|lvA3Sz>P++QpK;|;%Fxc(;;xds z{jx3f1TjOx5T%GRWCEwHM9Ia8OR!??hvPUl#(eL4|8eMa46tVgJ0|eTB!0gauTJIH zkB1)DnvIJcmF%>SY+)krXxS}s+jrxPcYkc)Eg!Ppy`O&isna&zdFVveUcLoD#!-(k z)x>nM7J9pobysWwpft3>Y45yv>08g6n3zb@20U4xojROP>R6{s{I%!W9J^AXC)nH&BiUu265&oc<1W;N>Qz3r2bU3I%MPH zxUReSQGO<$_{xJ%;JWLsw&K3M$E4*)OW2qd^e3u1hIhoYDJ1 zdGNu%KgtxAPycM`%9TM7_`Nt&io$}Gz&l|DjvgS_Qd$VfInNuH!nP%^zWOTX zf^%j=G}wy;lVt6N+igf0DMhRMTilAvZqw#mNGZ}ZU->SWBmio)={Sz2rlzEnQYwHf z!gh!S!bZEa@k^8{Z1mAIO*rRS#-vo9X9!u09V!cil){F|u)DS`#j}#N8}6vr8?$j0 zxl)?a;+4W8rOGmqqzZ>M4y9CqqmNqRIJQdI<1?Mk&it{#ZQDc2 z6s*TJ7as=%AWF$1de3i_LJ0Aq=en-zd8JlC3T7p(_&8c?$8iXuN~zZKRTNsKc>M7n zX{`w%HbjHHTQDnW#mAWqW}VaNd>t9>rSN?}49oUhuf1Ea!nDW734*Xvsg%p*!i`Jz zD}{~n)4E$ENe&wyrx5g_{B#}1M=5&r=qUdOrlHljlI$`f00000NkvXXu0mjf0I#TA diff --git a/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/dexsuite_kuka/physx-newton_renderer-rgba.png index f169a03312553171252ca317a7141089bf31b7dc..e59a3288aa6405f478abe9b49059c24ea4445fc7 100644 GIT binary patch literal 2693 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 literal 5994 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 delta 462 zcmdnWypm;tiv0&q7srr_Id88UW;q24um*Uj2*Phasdu55Ya4UgEIH!HtN__Q*uJ#dUIQYU<^`-}5QJG<`AX3~B7 z`_Ij)mUfr53(g!?%es0*&MYulWl>mgLVH(~<4jp8sjUb4H(6&|^YltFvqB{!quSLr zE!MUd-0Bv-cE_A8ol&WQ^?WY96FZ`66=k)ujy^3}vBKBsM51?!=~tCFQ%mNFA2lZy zu-2<(->Yh{zLtM`_VoV8)ssD|E*?ELPv1WN_w{YXF}G#%;ujsOzBxB7QMq*E#^u+N z4Me%NFiA^iZH(C}x6ruzsa9Dg*On>oKc~MIloK*_Q_5mZEnBf7RQUEnpo1SI3PXi5 zs;)3~FA&dM?cFc6U%281*kP%GvRXk|sY^>UO0Ha*c6ITb!(y_gY+b*?ToKN8EzQ_6 iL4VT27*hre@b50aG)MG*d6_dGL2{n1elF{r5}E)MLC^jG diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Presets-Direct-v0/default_physics-default_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-Camera-Presets-Direct-v0/default_physics-default_renderer-rgb.png index 0a83503793d558c620b05a01642d0a491142fe96..5e2f31e58d0de9b9678e15fca52286c5b994a6c9 100644 GIT binary patch literal 3582 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 c?UH?q# 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 ZY-Ep+@~{_OmvbMa&ePS;Wt~$(6978-gp~jQ delta 428 zcmZ3++|DvVrT(0!i(^Q|oVV9EW*s&VX$#yfVe{j!{MF`z3;0e7IB67Ss{TD?xp?{M zr`>up)fmu0L)_FLFU_^fA27ejk%(W*{$SIE>2X<=&nok559NF4hTb`>w$-DPfA*CX zE?EaR<*m39boB0pb-|~9T{RQjvS?lKHsQ1re z1yikww-kQuUl=6qc!YQAEeosq8&!Wy-~9gfCH3|;waH65Zrrfl`q<*uxsNYw&M((L zy~$+elclAx>e9O(Z8Kz_;w5uI@P@?1hw>9Y@YjFX4|J*fgZ|pBat;29KxeJr2XxL= zpaVbKY?pgzv@)>sN>l3H3w6P#Zzb0jns2#(rNaFJe-O~&a%LdON33@*_}zRkJ4ozl zsI;R?ZqQ;OkSpc2wmN>PcV24N)Y0yn6{57Yu%ynpx~AfLZT+uJxzUq7gIsdn%)2FD zD3klVqGaCl%hOXMXZd`(v}@hu-q^z1Gnlb%7 diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-RGB-Camera-Direct-v0/default_physics-default_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-RGB-Camera-Direct-v0/default_physics-default_renderer-rgb.png index 2a9d492e5e57392a28ac6e9f1fd035922fa4720a..5e2f31e58d0de9b9678e15fca52286c5b994a6c9 100644 GIT binary patch literal 3582 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 I>p|py05&jZ8~^|S diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-RGB-Camera-Direct-v0/default_physics-default_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Cartpole-RGB-Camera-Direct-v0/default_physics-default_renderer-rgba.png index e1bea2aadd7926706ca8e9f23f0213218f93cc05..25bdb10e17a86731bdc9124b3ed65e4643a2e905 100644 GIT binary patch literal 3992 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 QKOk3ny85}Sb4q9e0I~R;1ONa4 delta 509 zcmX@de1&C#av+nmr;B4q#hka-4YNW5Mc5vA7O*yo6mqhij#xI&Yla*2mk~bBphkSMA(h`03Tr_iCPOdw>4OO-!nPT$Xfu zZL)#5SO%+_*{Pzfd9n-bt#|(hqP4+1n;pD&?%bCp`_OS!Sim!(=Y(+imqT zbhtXBW7SoW9~J>yn~dby7lxkxlYD!+!xqW5u-=JG^6aaQxrD6Uv_*E-iJpI*E6P_y zuiY`HKkTYjca_WvDdE7aznscetP-8R**P><%t>#Xi(9b&qqWP@fu4|`tiZ@$e;^0! zk;6BC9y_^vaq481y)TlI{PXJT{@P^De!Oq<``PM&ALrRs@8#&ea`ecy=;H}`Iv2Qn za@IV}x@+Z>K5cdBn+rM@g3j6R&j&dlJ5Jy||cb?&-% z_oT#scYi&4Y-zfINZo?n2aawtnt4v`a^y=<)0aPw{&C=(D5V&r?2^^u(JQc0P|4J3 ziCf1N4xEC_xZQ+J=EX#iJM+TNubn>obLB+Utt%cSRlh!e{kii^+1X+5-%gJ^Yi+S7 zr^;+)S9h=X`2>wW4&{yAxvBdi&WWGCq@8u?bNA0iA%d<#aX)6r9oYYyv0A`<$?Wo& QKOk3ny85}Sb4q9e0I~R;1ONa4 delta 509 zcmX@de1&C#av+nmr;B4q#hka-4YNW5Mc5vA7O*yo6mqhij#xI&Yla*2mk~bBphkSMA(h`03Tr_iCPOdw>4OO-!nPT$Xfu zZL)#5SO%+_*{Pzfd9n-bt#|(hqP4+1n;pD&?%bCp`_OS!Sim!(=Y(+imqT zbhtXBW7SoW9~J>yn~dby7lxkxlYD!+!xqW5u-=JG^6aaQxrD6Uv_*E-iJpI*E6P_y zuiY`HKkTYjca_WvDdE7aznscetP-8R**P><%t>#Xi(9b&qqWP@fu4|`tiZ@$e;^0! zk;6BC9y_^vaq481y)TlI{PXJT{@P^De!Oq<``PM&ALrRs@8#&ea`ecy=;H}`Iv2Qn za@IV}x@+Z>K5cdBn+rM@g3j6R&j&dlJ5Jy||cb?&-% z_oT#scYi&4Y-zfINZo?n2aawtnt4v`a^y=<)0aPw{&C=(D5V&r?2^^u(JQc0P|4J3 ziCf1N4xEC_xZQ+J=EX#iJM+TNubn>obLB+Utt%cSRlh!e{kii^+1X+5-%gJ^Yi+S7 zr^;+)S9h=X`2>wW4&{yAxvBdi&WWGCq@8u?bNA0iA%d<#aX)6r9oYYyv0A`<$?Wo& QKOk3ny85}Sb4q9e0I~R;1ONa4 delta 509 zcmX@de1&C#av+nmr;B4q#hka-4YNW5Mc5vA7O*yo6mqhij#xI&Yla*2mk~bBphkSMA(h`03Tr_iCPOdw>4OO-!nPT$Xfu zZL)#5SO%+_*{Pzfd9n-bt#|(hqP4+1n;pD&?%bCp`_OS!Sim!(=Y(+imqT zbhtXBW7SoW9~J>yn~dby7lxkxlYD!+!xqW5u-=JG^6aaQxrD6Uv_*E-iJpI*E6P_y zuiY`HKkTYjca_WvDdE7aznscetP-8R**P><%t>#Xi(9b&qqWP@fu4|`tiZ@$e;^0! zk;6BC9y_^vaq481y)TlI{PXJT{@P^De!Oq<``PM&ALrRs@8#&ea`ecy=;H}`Iv2Qn za@IV}x@+Z>K5cdBn+rM@g3j6R&j&dlhMK?Jmk^B9&++t*INp5qCTr!MuouTHM>&~G=GQ zqg+BI!?ew%T+?h~N6Y=U)9?M?`~A=7{rG%dpV#w6)p{pgs02PEYJc6*{eC5T#vz6Q zk{d+W8oYYH;>?vg#(arqLDC7mpV!`Pr(LL( z)^7Ppe_NcA?N(HzgFLQvm^&DVL(v$J-C-djhqqfg#ljZFqiGdCM==&6I@jR1iEQOzCA0&a&E>{4PXV5%*FGe&++#({ZIIR)!GyR1|0Kb5B8xcNchf2v zrncFb9n>=Ln98a}olKV>Wi~Z6)r_w;#GD^^d?g?EVRLz+Y-5|dVg-LH4l@B_yP&hY z^|@Byh)-Wl!l%{Pl)3eYSjtQWvveqE{v3=1dDMSru;Ol1)G~Mb_NnOZ)Lm7PV)!+< zUq475$p($j&gR&layPy3*J+H^ zSV$5F+vQ;mlk(aTmu40Nm=*SN(HsQ>!8_=iKiU2-xY z?VlF%vh2~`FawZ<1(L_{`{qH8an$OUD2YT~TSSj6MJLFBzjWhci*&TL!Tv{KYOK$) z?x?>p+*IvnGOKU`lO10>zP-z9b{m!$4=26W-Q%wpVYvHqqq@^+em23gmlVWG+D(L4 zjV~<)w;#J;$xvZTy3!lu$(N@C`b&Qw+`w2MLt39Ol%oixMNU0)E=|_($SUYFq~5aP z?dGKJWBiBm>v5)xy2GPY+66YF+fjoAPuA)x%&@}D!omlhr^iXCT#3M(;ug@IL zPEW6GZ4lDsKkWoB4JCz~v!~<`9hd?t@aZUWwR$wm5iXPYyz{(u(faPtJ}SBLou1Fn zZa|GZ<;lBZArTRgyY&J}>^)pHwICw*u!E z;@|(zSgd5vkwDs&HMvFb>|URu6WfNpjHo(iVe2f4H zqhAD&xrDcqlODLGsazr&yfd#kN1Qs-6yw=>M=hJk1w@J>VaT54Ov|B-LbqX{CXdH! zvG5~!4vBR1+M11xtlAEu-G>T@+}%S}=x_ov$;LH2bA;O&MXpGh>{6ghs@T$PVF7M$ z_M(g8!Evx=qmB7FF!)~fNJfyAqS6UtwGT4MF)i(PwGIsmKikuF%~ z2G+0aTxNw2@+$;RzX&7D^&r=)%F!X;ezPQJd1RYaQ(pV_AueT2*N>QK0RPe9HJ+d% zo$&_j7ZpDpD6PE#FBihEdCQHjx@(9HA}(LYp-41tr~6atc@mQy!O5CT(OHspQ)SO+ z8vlW*-+_9qxA&Qi#)!XL-hQ@Lu!tfDRlm5*t>$d-#haa8&lK|rHbN0w>m8DJRkeH8 zD$8@$fRN#Dyj{*yZ}Fu7-8Ks4Ch}0$r%aC)PD%6D$I z9A+gk8t<{elzliBhJtLgr0417b^8POPG|-TJF~Rh=@u#>9ud(Opj(LOO161gHW8bc}mv2 zue4At&jxi7M886TvIR?@|4O!rr)TtjenIzAn5vW zJIZZBTZyCy@KqcVR%lZ@GvVZ`0hG+cx*|ry2-{p|F<2Bsm{q4NEO>-0LXvvV$TGBx z8IQUL&<>iwn6_Vo&xDg^C(t1-Hln|#nQv}?`%WVLU6mYoC?TM+h!ev7&8wF^YFMe*A5-f+Y;|LA41bc_wT50Y`Fg0I0$yLwnx5 zeV!vIQx#@F6)dql<&6BU3fm4fl?C94G#bOp{8?PjTyrN>E$EAxbBo~ zH}GEvr|c-sET_F6KYn~yB$ge96U;`X&SWjQvt#*KOW9V-U&wf5_m?@^nKHx45|JCm zf7VcBgGyg!H#p4?NUZuo;1~=hxeI#Y|M~Tzq77-F3D$rUlJ}_kzyErkDoKWeNl~bi zatUw%1+~Dr|9&)aHa;#6h<5fzSL+V&Wgb^%RQq=>AE9!P@lHD?UV*)}*^NA`B6!a# z8g{*Lu3gwo3$13dWv81QRYl9~8YUr^E||=w#Kh=hMn!xIxooCUVxg}ngr!OQkGxNQ z{O~l*c~OZeAM(TBIdAQH-u^0t)$HH1tf6o`JtDBscj>l7QdM~mSfz#LTsuyUQDYM{ z!OS+Q$TLGYpyDZse<43sSMdS+L6!(bj9C8x6X^1*kjTDu!^5Zhh3o2X>fiP%B{(@y{D&qe>I#0PWvcq z{w*8oYysk6!D(3Z+@aBiTQ`%YpW~+QTeT8LnOSV$2oKTJjfc&_M@l4@%O4SM^4+j2 zTl~<|E#1LaR$1!YMf~#`0LcM!^#M>MK4}3?b(^JgJG-uV435}B`lBLjQHI+86 zU?8e$cP!N1j-tp)M07cQQ1rb(WAxwoB3Y`Q^ICTTLT0jCTU%W)=Ju};(n6NscX@Zc zuEl%1)#!!1y4C0jc)|p;AD%*{u8W(Rn!3A2oP<3}qRv)VZ+_#wJD}z1bwQh_SVNr6 z&CTe`Pu`lm(T$zDfpZp&hL3FlSGesfiB&7@XxJ*@Q6=!~;H!y`W42G771%8(-RbEa zt0~CH2Y%E$*iYtlt{L1EKi*;12wuB5BetxVA85Sj>Bq6h3#7G&Iw1?=!8n-sOrJjk zEi9NJ<0U%0^Vqo-jK7j!qM(UuBvq~8sHmE{nW)ehi^k<-4PN18*t+MCz@GaguOn$x zg_BrC2{0TL3Pvi|cB)8;l|GS+E8vUQ@x1#?Q=KN|ITr2V$6y*Ewu4tX-GiFP+&p{c z5~kjDs0(HE&deauJG@UVxvxAMCL0o3P!|BKJn84kY!Kni&c?#vpBR_DpQfWF4uqV! zA6Ij2swZ;F#UgTH|L#NV`A%qW6{BvnQVM$077(Z;nDJ*WYet_+#5P&Mq<<0RPRP>$i-^o))xioWz6jhz%X4?TS-VQ~05y7q<#*s+DNp+|f1+VR%1!;(lE= zlHibG0Aklf&4ck{VajK65jxnLyf_mfE$b%Z3?si;2@OPS^Z=tRVt7aqo(OTI|$kqb3bl&(@BN%^44auivj%7a0~6 fDM)MdJ{ARkVuheC;>wiqzlrKA`nnIL_m56K>}jvB_u>Z zsY;P9CG=hdq=a7Hz3*qfnQz{I-{+dy-I<-)bNAZwypH2{UJ=@w>esH^ymIc`xoaAa zRgl1S_n(920&uo>S<60mjx|+7MOn`~V=dFrnMIu~6j$rZ%3;Y-Ne#8+BSg9-uJLp5 zV?Y*|+`F+%{=7_){EQcwRaoPA@3aR>+>O0(H|blYx5atWbH4bJjhW7j(y{UW_2n+< zzlv=6`31Pzl{M)C&1d>-FyMv%%@Ldzz{<)RxR{!ns*x|%eN|f?0TEDCQsOCO0W)wx zSk%!oa~?JK{OGJ))g%U~2li81-5e1$TwfnBNHX zkAB?SE}#4redL*ktR3vKk{WT4&FCZ-~7%__N;mi&A4hJ+-LSTXLpJxK5TAo z&e_Z7*`MuA1|&!z;#s5-@g((<^n^Z%_ah@C@q$`6Lwh@#59aa^W4QsNYo3H-k(wph zM>IJJKF9l;!@~xg_vnI0-yeO{nh@46q2bF*;PLx=-qys>Ff}FR@n<|0&J&AdG_6Oy zlnk{tybUJVKR3k=%y%S%(aU|<{sOISS4A^tm|}K}sbhdhi8A51E=?%-WwlX~wQ7-B zwbAuD)y3wqv5X}=f4HIVRuhAgcIrmMnSZnWk@QRZ${(TTRYsz+DtlPfy*&*^Spu5ZXpq!$6EaOIRXzh#EX#GWQ;lySLKY#X3b!Ng)=c`7amN@oasShU#RUK3eaZQm-h!SYr3a*q z6v893ei`H9-$|)lQ4EV2?ei5`vF*1H9>bd?Dn=YW_5KB`?2WA(+)*hG2C<2m>@R(a zX`H9nk_N;Q;Tf@_!?6PM?nINyk)5w9xWRTSshz|aS7~8kVLz=9&|IZR#}%$(3dU=Q z_3xGl2&Y>*v`RS~9ZgHv`a9iZob+&a4P>O%vr#|dkd_a|?i3AorOR=(azRidtVmsB zhg0*E{AGd+u0WoJULpm^^Y&u**zF70~v5Dayt--M~G;_x|Wkwpq-K*kX zd4oR>+83jqzz=3)l(Lip6irUX^%;A8MtOrJH{Uiqzz)pJ%+&er<>%*f_Qh}S?wHnH zSqwL!QO=c?>0#$*%gP_M8asVge`MRVNX8>(`LP39N+&E|M$y82$P$ImVscRRb~>83EK*D6gtt!HbLT2rM9`ZN!P zTUK9KSnx9;XgP)j-nG{kx-N?n3$EU(^6l<;PVyAZ?UGVBU8e;4~Pd${iL?G`*`8sd-4PLozCko9(ueN`0!fA01-xdJd>E4DRz(^TuarlZxDvCr^C$ z*5_l^Jkx89&|AhAQ|L4rLl_vR2b^Tnk;X3kKHUVt?<*@`Mvre`+P-Ue?^zwq@XEan zRiyZgyWV72LEE%DPkaTNpt!GUWh*8YiVVlZzO~oQSSwAubp*`%pOt%mo)LkKa|8tk%{%rI{SV9WI#H{&`tkmFh^^GiQ zip&yku5az%Z~n9M;&dxqk=j&DK23_l`5YdXsmG#{tWb93a-R&LRJyxLMQ&UqLyP%dl z5cPC_P^fikRrOEG>+35NNS#q@jKfP#jUcZ!Kk4GXuUTgPE`CrvTWGB44DGWS)De*(zw^;HGN|_W@hH);hOMq%xalV+>E8ACB^4C zc+f%iRdt#rI8l%@qo*;^L3Xvi{Z(mQ>oSo@MBxUVYL>;BiLO-YF4aI85KR^?XS7&s zo}L`R(z{+HAP-3?6d#4;4h=`}!G}J{&81IQknaT5RQAlT5#xJ%dlh_l!`E*n+Ef|} zC5m*6HUu;Tlq2D+b-gd=+#c`{&ECvkclrS+y3=Y#_;9DeV?#$Q%L8Y;{vg^`-43^X zAY3RK@oACDWO{GHcVlG`r(>UWD!}8ad9(HwtVsg+S6c_ru%HmwJN++!^v7jQnp%Rn zEY<6;b=kxpPMRkFTHHpq1K%+?_{?;`bG9{VYT^Wtrs&U)Ak=#4to@KnqCR51&r&9n zylAqzY%Dit$kjmz2h*{4331RWUp?hafx3H-U8sK|-LhBjhoLMFMam7#%xXk1vU|hT zuaXxZ=$D#$82b%}hVmh*?UoZ~*O+$&ioV*8|JabZc`e#K7{oUbHaxMG3Rxq1tfKb$ zYZrbnOkfj2V0_u#m;G28%F3pe1))(B5_G1La(ND=qdP@iqWA0g*Cc$nSxLpvAI(=A z(!WM|V`ltu-OOR2e7nAddqk6xQna^TbR$mrxA2^Z^6i=Ap zAQRy&GKee%PJ>wiNUit!BnYJ7J*C3rER4|7>L*V@;DtuTHfaJ~)C?CN<;W_pn>g%> z4)qpd4Q0X=VHj^qVS z0Xhg3hr#(&zKy3?yel$kfOI?duDh@h1Z%1tR6?IPHY#v3jS?m+M96qbjo78I=!pK> zkcaGfoWW;lmUea#0kx%p^Co%jmvF5W0qc!^%_5%*q1 zkF$qGFvrKoleLMqENzv@HeT-@SaOb>tA(ANM$E5q6g_iHa!ZS9H$jyyE3shD!Z`QO zI7#0Ov66zqxX!vi{4?1Q;NxTD6xzV`xuvE%ob58XV`e7zR0saxHG}Xu8kJ&{drb6y z6IA~TvFNDWNW$l`)VUS??qXSBH$o*PC1aPWAp-SfWguvGXXjI?#5bniB?G2LM!D1@ zRiMU-B2N-RJsfb1IiVN5N?kV6-dP6+Z`v5a^Er>gEO2^f8S-AyOt#%wOmy z+f(1E$oLQt9LyJ-5XVH#lLy#?F<6y{($AFZKKL&qtHKXo8U6-b+VZo$lHXbL!sk)x8Zmc`@n% zN){0hQw%fQZUZ!06hRzvt$R4r`$;`RQ_N)9ZYI>cXT#$O;UwR#^_gg2rr#G(?zmIs z$pa?95u@C402N4M;_=_-yEEp+?|DgO4Ow`*_W6|)n*?OO9RT>R9{!)cexg zW^#C###Kjd$6boshF`l8EuWM8^N*R0wcW&SlAtAqN6HD`7B4AI^k# z1c273t{#sUz(W1@|0dn}fR=|yd3E+U$Rhd^ke4JwKz@hI1uU`dZ_@<|3yW2Wmy-&0a*z~k}mi@!|YfN%DSBbd#j zP2)B`%inDP#F$`Dn#Os{3YzxmlaPf=lA!_j_V1r8EPLwV29?f^rK`?7$K+3@0HlLG zX3UTf;O@DQfW%-hd+X!rX=!ecwSJNNec!OIyNDj`Z4@+zTxAQ?CpUblD-xvb(*q(7 z^anD=>!m%TbcuamTR^xM)wd%mCVx9U$J4!$>?=$v1Cj1$Q^M}(dBsV^{I5s zGdLxZ=)npI8ynlJYNO`zd@6q?M$6RRh+M#bZ8$utG{J)%>k1zGfRPb+KGy$2l+LaT zK$jYcOB-8TZj%=rTh10yXc{C)c&1F}fAOnDv)#M;F>`UngE{7Y8R^vb)`mVJr$7U^ z6yh5fJ<5_n!$;S2mlYENQ_e;)Aor6zcFXNf$h@NP=+E1~-%l|81_)mV692QPw6qd! zTRDP0+NI^A=e+;W_P6~#p0i@5g{dH5rd}fVx@{_P|Bk~uFHyE09v*&v&?H>*6HxWY z$AW@_Wopg+QmPIssbUmH&^`pzaI%|D!x>Ldstu5KcXto|komDsoqE{GF|EB!UeJOB`~PrmCS`bKag|%Tyd>sf7tT{HnOSD)O^8E`@w>PcFgZS=5hz!t>79Ln#13ZyjtKiaJ} zih^mlEw)oWD)MP&LRykPAn7v^VY=zr(53S=OOhKCuS#4xf*SXB!Pu80us&Ub&2)n1 z2abnYi4RWQGkyyXQC5d63(fnSpJ6pX)gAK{v#!BzEzm<}Q|ihEkif4+?J#KwWHbOn z6+5wQAC~Y(FR~@~^w~EtabnNf$j9VSAlv(qop#ZlbWKwYKCrg!kCxsaw+~lKbxZ!! z9B|2d-WF8tG#!(mb7K-Ix1_s`l|l5^@%nbWe7Yyxc#nsZ@$man`TdqcNmG7W7_Lju zn;U?nVqGw0m+XAD)gk^xemSoS`}I)$EQr4E57biHm3yp5-D^)Un3wb)D6X;Pt5Yes zXHb6G5zsrs=g$*I<7i7Be!~`%RbN*>z}yl?NI@Funk2>~BN_~GuZL9jdua9We+=;| z`d>^-Ysijioi?lH4NEDs(`_L7Vq$;i;&xT|16tvYL254r%iD-*uK2MkwXsT0xU<7Q zLtF6Ypzn5<+yj$d=JQISuPjZPSurHz#;#AY8)L+HMOlE7iWlN_)WOx|0SBU zk2&Q%{|uK~x;~qTGQZJlW(bd-vM1w<8_HXyXQIEwFlT6AG?f}6ZE`c2Y`n02rEF1%3fQ*6ilj*IG!cPr64uf!}fnLYoJ z`YPZErxlL=VR8Pe^fl_1nZQ<(pJHiPP?qpDkKO}g)=cqfyFed1nZ4WC;}u-#ae7(Q z##}+6Y$?l-)~+7e9K#8ZP3-?Jn*5%`eB8U@cDS=#pT-kA{FOijzVqfK7;AZlxyyLD zuE4UY&GLS|Tmd~*bI?or>X56|!R0ss5W@^x`2%jnUaLzkX4 zCt~Ro7qR|efrIv~*EaoaGAeq~x1|+E1tT?L+LJ6@tPO26xSZu&+wemm3=c z6DkZuAOaw&OQPhZAMyh0221L(x_^n&;kqyCRK!^^#}koaACO=4gf5zh$-ME5NAAs5j`rQo&d&Qn0<|d9LOqKH ziz{?Rqre?Hh1mfHA*=(i$wU^f+llkNAW8G{*>D)J4Kt9hPl4sV`oVK?JoCvK<4wfj ziusAQ#FRZV3(2=N67SfsKZ8O4)a1}}cv93CyPO?xN^2G@8=v^o%8h6umi)xxmWEHN ziGCy(M+`PJFh@Jx)%bxI_|dcpM8F?tx&Nv_s!s_#0mXXoGygp=KFDR)*7SjF8*VIwUl#`kJsJwYeK(|^iQF&j5m>vTS zp|bR2;@a80%jzPB%5Dz%cJvkMd;nlyc36szP7oZuEc%wc$kFu>Hpvj1Qxd;0b!qZ< z7@HB#3TrlV5Hp0nJc2okRNOa9yLFd}f2T66Td@X41D<~iCEV~~W*f!Z_zeA=q)$+P z)7rx(QL6xX3f0`{I-;S!cCiS0opaR#(LY!G3!iM;+#HgexJnnTdmb!J5XbLe6e9&= pX0qBYPO+<)rO^N1q1U^9pT~lZG%26hb@ER%4OLB*64;Bt{{fn?HMsx) diff --git a/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/registered_tasks/Isaac-Repose-Cube-Shadow-Vision-Direct-v0/default_physics-default_renderer-rgb.png index 1936f5b320567851f5e8bd0c0a5ec7c598cf6b0a..415da3d1a9c9c2f9418fa3d54a9e9f509b2900e0 100644 GIT binary patch literal 20714 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 D4h>yS literal 20514 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 B3{U_7 literal 22799 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

g1mOKl^{CXCI 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 delta 1836 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

IIAhp?3|)$sT+XioXX=oj(Ijx1`A__8kNax# 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 Ch|kdg literal 20688 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 f?X diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-rgba.png old mode 100755 new mode 100644 index 5575d93246cf018f63c0a76c43015031ab27eb12..83632fa9d89a4335df0d5a67109d624e8cf50ff6 GIT binary patch literal 23150 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 Q9_b4jD!R&8MeFeY17SOwl>h($ literal 23258 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 Vi;QzxptJghj>cp4FRHc?{{y&LP+kB4 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-semantic_segmentation.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-semantic_segmentation.png index f3e2cdedae07b165ed26195c847a1be5958da594..ffb770a4a55546b0e79fcd5544c2c8794e4a96e7 100644 GIT binary patch delta 1331 zcmV-31hi_^du997CFdtQFk)q` zIRLYEhTfAv&SP}VdA5!{Yp9Q0OMn$V7&*UbbYVAtv;Y16UTs^ye*UR{wyk93+}Y^D zF6Co&J_>%t)q5*Tx0Z895eoZezp6(KI!ht8BIgc9e%y7>b@#j#WuULS2d|TJW05-c z&3+K01bw7d=+WP2ZE|iXTF2ho55i^hSj}`@p&~g~7QL`DPxegYTv_zO&Sb^z#AjV{ zj(Co}7ySl~$on=EIaj2AW53T~WDZm1JVVEx;WN*vu9yZT=ZbV}SWxuB&fLK=k#l8{ z3tQMZ$hm>Y=h)G&l5WkipApHqfoL6jKX5bMr~B-_;#uBD4{~lSQpdh*p7(*uaO2k@Hx8sXF#E8CUj< z4stH@+@Ml}^ROIesdCQTv2T_NltZaW&OOdm}{#UBQwZxXsSz&*+eCU)? z7SCB>-{&iOa*#8V@d7)Ma0`Dlb=mxVt+X9tlXC*K3j6G9FrJ(f=uy~5ug4g2PT*VT z*SnTJ6N|CrJXr6-z7cv3avo$nP#?LL04sbja(>h3!fyU%|NH&D+O~fE{8N8zTgk||v(bfJ z%E#(_6#R;-_g0o}E$5CR6!y)2RgW5UmO^Yr&K->Wxa*$l?s+T9KwozcUMJ_qB6aMW z{UAmO`be$NqrcDE?E`VAbB_iZL}u1J5!exJk09Hz>7hK@bMXP#4CF%3%273tWppy-92xr1dQ=gJ}% zwy<-Ma|4mjv7=ul-I`@TBa(9i(K_~i;AXl{_t|~Lv%HTUV%X}ZCi~aniRU=dpiMb?j#{uIw4fxspid z*k?TY+Ss#SDf`OAdA2>1unjrCX_iCrSJV!DHF8d1HuR3>J{&p8 z#g-gW<(#==-z*gVSVxRy|woE7$mG@&P#B4>sDuT+O?i7lVA!v1Xe&?%)X pp0mQf&sX&1AZL+b9vCR4{11ol6#&0PM4JEr002ovPDHLkV1lIAv^M|% delta 1334 zcmV-61R3Dhd=v#-H;a!#N}VIRF7W5_vy zZ=GN7TKY^Z#**`3y$kzB=sC!FkWqzQiqNx=^8nvR??6LenNXK6{@*Lxr!F}^*?|!& zYs~?ewKMdd1acmuYtFNE>{&y7k1Xgxw7bmoq4inBInAY7j`Bqb|*gT zl5@m!?7iqWa75m>naH^!e;xaM4kL4zD(4wG_6(nSPIbjJC^=W8W5a@?7k1_jmWiAz zi(J^k&Oy!%L_WukewB1%K&J9HC*!zK-=|0_O_Z83bK6;RIW05-cW%IlroDhTc zz}fwM^ggEP25*vc2T?lq(Y?Z%M4!|($hlL_qdz-58*0W$--?{ae@fM{pUJqgXC&uJ zBAsKO@#t$~&wi!sD--A0_DsSyt4#i(lJM`7aIf0Rf;;jb#%yu)J%S9pQ0Y(*e z>y`A(xmw!LJCgGty$ied#Sx*8A?Lxe{P6v97f0S8=LBk96^}k(t$&0q3pr0BdQdf4 z&2x}*ndb(T8k~paXdvg3Wc9jZ_)YqBkv8W{9ee9hs3}(TB+}-bwXjP`9C|V-b6)yz zf1R_CMOS6scfvUF=XcNC$pZ}zKt)S$BzVk>g)VC2VL_gr_+TTur3x_j_CIX4!m zW8drtF-p)!YK0#Ceby%DhN5-sz5O6uHjmXz*A*&~b7j#BJM(1EM9!5(FYHWK>`r{v zCFh9e*n81$;E23$Gm&#ee>(R297g6aRn9YX>={1uoa%~cP;#zF$A$$(FYL@6EE73b z7P+v6or9bkh%jS7MI3Wh> zfwTMj=zUDn4c;W@4x)7IqkDxji9V@okaMS;M}KyBHq?xhz7;u-f0e3ZKa+7~&q&Ud zL^{VlK0?U{sa$oWmP9E!i9cIc~-a{?m|#aj*fneApcmy1Hq1B@!{ z)+_0mbG5XgcO>UQdKY%@iz7lGL(YR``QiKJE{?oG&I#1IDjt2nTK@=J7IL0M^q^|8 zn&%+rGS3YvH8>B;XhF^;$?A2-@SF7MB5lr@I`-D1P*bevNuC{{R30 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_constant_diffuse.png old mode 100755 new mode 100644 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png old mode 100755 new mode 100644 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-isaacsim_rtx_renderer-simple_shading_full_mdl.png old mode 100755 new mode 100644 index 7146c23594002cc92f2f37e40270cb5fb2bbc88b..a4b844930dc2ec482425a23af5c6b34be7309387 GIT binary patch literal 7144 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 X|1(QC_!9U94^mftq*S74_VWJ#)8Qx7 literal 7134 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 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-depth.png index cdc12e1ce22841f7491f6c885df945b994ca7117..720dbbf9f140017b7816d3cf030ab7601510ed67 100644 GIT binary patch delta 3849 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 LNkvXXu0mjfQqk2m literal 6145 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 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-rgb.png index 071e6d9b2dd23786dbc0f3ac57983b0d3c0df1ac..b8ceef3ec3a5cbc267c9ef4d4396561d3a2a881c 100644 GIT binary patch literal 9313 zcmdU#hc{eZ*!GVudO{Ep(Sk5RM2iw7dJvreP7r0yHB{9syrzX9T5aUq>2hM8sPik^%o&FINCU2 zvLJ|+Ls3Rb%PSp|;p;@XoYCz+P_8pEQf^nqN<7|ja(g2{lZ}ZrKQlp@!MU0DGUq|oH z_S;TSvNjS=JFRh~OY)yayhqV1^fkh{u7mR*`lTXSLNJQCFY(b3lA(XJhhwqy20 z7pv_~iw#TnZ`xFs;TP58BoKW!RzNNX2}gWVe^6&qdK47ip+mg;J50yHL3o z+zCM;6w)7%$ktZzRX1c&(OF56A3sgk2tl#O<2(Lx1+xsw`<8-fPf|Yim@|hsrRP*s z2uV;5`$l9>xGL4<|-<5PvExQAt~e`ySoyfKny+&pfs^$WkOy zZ`A4hcqi(v(1ZAr9`ceh!#WSlgbZ_|NuUYeoyp1R9j})Bd|WVx|FHI0@qaU`%*hGp z(#SG>w%EX)w6?Z3b8Lg#>Mt#__N_KYZsWG8Rkx+h zN{)z;k&zJISOqPL5b<}1GI^E#{r!r3u@>Izqh%qyf@v7f_A~p}uk#A6d(8Rhq6&vr zWtd}?3O?!BcB;oJe6pK7IAwA0gXe0;{4T!`OJw9yY2uFb%Th~Wc5I!0 zG}s%n#m@<=4%N7c+>4UK3b24f$NNJsX?To2ao8~5+*(aK|wx$b!Q+1u5o}$9YeUF;&cK3aCsK5WrhY(s@TO0kepCH4RzZ2O*gDW=+3+B}G^6@hxK`mZ>bp6W zG-0cdxVX4#(UW*^g?A7Zt1E4FbxXf1U%x=WEZ}u*ZO>vAcu81#cVF;eZp-eREN5|j z_c=Sv&?!w^AffM8j4S3)g2V3&?8Sap5Ehdj0`sHHX3y*7(VVfY5{PNPJdhd7!4f;; zhH9XiLE0OT$zuD~&Kbdjo?Ark!HsCK9x5y^x2@E(L+8pOk#Af7Y;HP4cEu^PiHc0^ zc+o@8E|1%>YGjx@t3usGrqO7@Cl>5jBiw}(J+Hku_`j1<4?w{D+qF2qQf)=zpFF@_vp_#rIh<-SBx zKdRG%vBf{6i~1`wz@!+*NLZ0%m%O={C@4V&Wn^ToG6>5L9tdhR3xCw_n5nau-z4Q^ zzDS~oqZGNHhgQIW_41M6KwB+)hdAV75R}W|?&+x*XR$Lc%ffOqsn>!pMGvv_Rx~Y* z-2V6Wy`<79P>I`o8tdzqf85@Rxb3N{toB# z++wuV-|ad7bO*L-!rS*oxXJRUw^#9&doy~>*(5Gc>8Xp6*wFe|MbQF2B zqFd3{{iYf-eZ~xJOf|lU%m4N3*X!;-dv8Up=FRz>57tI($I9)0^TKP?h|pA-O*Jkb6g0F|O~InF;dE6*M1;ioTsuGMXqiFxULlzF3ZvGh^775u&R~Oj zhehFN3NyZxxSqe|hN4Bjo8eN|^bZ$TB5?znH=EWs#;c!pVP)s#W#F^fv|-Jn$R+3x(J8nO;U6yLRrU;` zm5mk+ZEd*DlAWF1RP#}y^(Mt0?*i^tXv&Wc20(`N$eYimz@ z+8#0p-&Vt5FKJ=!-b*ZjiBn0ke_jd9w)%JFDK((0lH^efz{=30}eGf%>~VDc{cvg*2b)^HoC3$THp3^QNx%a#d}myCDO9@85sIw ze1Iajm`?ak)vU%k3Bw}ZKp$jsC+qC}7ozg;;5VhsDvKGDy)$`|nK2t1A*p9Y26YI; z6Ynmy;LeVo`1{x$#5`gW)1Thl)M`$iT8SJ|GJ8{u)OVT`bo}Gv`!_aY#_)5;Q{feI z+#s<59LWCVUQd}SP5`VoBJM_*JYk@hPtYQ?w{kiE;{Hc_@hq?T@StSzcYbeYI|hoJ z?Oh=tf+UTDAKfj>H7SfWG~#;9!OPy>{@uH?j*e`Y!G)i{r)Sc@^4UrrB|ltqsNTlb z){vVfBU*r#B!nUl{k^J%F?nG}7|Q9zeg8F-ef6dWCnvRsrzfSf6%Hq1P~V$B^;?!C zsB|%Jm%da1vakhE1e6MXt&d5V^U@N9p19TZoAVV6l~`t7Oy2qM{d7- zHu;#SkB~)fxj9?jU_c++^;PAlecw7NMW(72i8DB=s2s3o`P_=^+9m68qEAvS+O20U zLH?+Mbvxq+cbZjxHAAJ>XuLGL7Li$4xLQE9-z(nu&injW^hEgeq`=nm&P}0$orSuitYY{ zkXfZ3XChG;3Y>LqlHG-bl(cVlgEG)2#$pp(d3qge&MBGuaWPWKu|q3clv_l!wUM@H zC`EzQE7KoSd(rGEF*Y`~yyGPzDtiCj#$$LLb&Zr;5w0QWP25NEia1dB9c#~c3wYMR z(Pr}%TLiP6_sTcZg}&bC#Tb7v`})aMywi(dm@Oebj$3k8yYKu|qpO951=z-P3=B)& z&xC#!#Sa=98m_vX9*kMq=aBuoaR>VA+b=SLgjW8xBasV!M6Q$vkmNcPhH}32;etNo zCK!Z)b7~7@Rcw6?k53bF^t-w^s<#`DQ}}wc*}C&-ZlYENNY}D=9$HUB!;GKK0*+t} zUm%got8AUV=p@*BLh_n#$I#%?qA7{}e&b_^nN`%llUPW7@PWm3@t?@soef^^RxtL-`>R8`S4&w%?_x!R zy7!E~E$n!S`TbScBlp|Zts#AMjEF1BJVo>PpNzT<@VOTU6blvz1ONJGc)zSiQ#7N0 zs-H+Pj#V*842=~)L5PbT!V5|27A@=?hf}gc{7~FFnZ~qkg^{7LF%Mx-E(a3nvybkc zbJC;0RnfEH1+=A48HnL15op{rRp9+!8sUJiJb`CqXmaiwURZOu@j zq$JPTCIqmc>H~hFYi%l=fR6wv`0ZWn^Y-mqtByq%^yi_~d~pR%XYWQX3PP(?CBi)G ziLOZF5~Rmgt1YlJsu`zQubw#^SwO|527a4UPoo!L)Fn+#6K|@4aB6X;^l$`%RZciK z*Z<}HZ6F@c9z5u+Tf91@l>UJJjupV6#Q!{Nz;ijnQ-czc6l1^%h>N?`Jng*uw`cZ8 zVxu9YTj^RuGu!Fe8T*WY^NQLYY?D+PXw+d8-uc641|!qcDmbfd6E?M+5z-Z|=Bz%4 z8{=wLv=9OY*MP%&cz^Qv?JPB*3;1N?>rGDduNwP7-lp^0=`jn1v zrYRQ|yeR1GlqwyusZ@CR>}1Tb{esNin@TpvR}DD+B8iKw=j0Y?myt9)SdyHH`t=Tm zn{LLg0W+hchoc5V4z<_L#zggc-zU=5s*Kyz;s-O)Wfr&PxMo3&gxHCnnwq|u#TNQG zRE&{AQBkE(hspO%6fV>}%|J%}gaoV-LZ$}~s6$Ql5Q(fi?`9^IX-ZYtG#!MjHzlEl zg=l;_!uLS}0wX8QY`X-<;3y|2C!M3lmje4O5G&DEq1&eZ{=3m|x1gx#;BP@WLPOHeOi;7Gj9@#R6 zG`uQLQt)~$%5R#vOawD2rEE~b7W%T?VR-b7>|WL#wFBr!{cmiq}2Ln%VJMvwmSv$nEY`I)jIrxMdm8QGFhnl0nZ7V8TUQD_&Eq^I) zpoUh*%&TqVUPjU7m6WW4fVp8Dd1^6~m;N z>L-0M69QQSLVSE*cz>ev9H-WDS1Ejp#Rp(ykddSOyR!lX)?cHeqmz>hdnaa<4V@jW zCrk93@Uv=J0-FJeNX>~!o(H$Wqsd~%)?42mtbPAZ2(=uHRyepf?)S9lylc~^ho&}r z-7jhj72Fdjqj zvPy}az@N-}vKViPJU(s}m6;#rzn(wGI`!GapnS5_?<7sWviH?LuaixpZnsDG`^2Q<+-2p-vR9Z6A0&;Ev0W% zuuEL*KY_LvlZ?ysD$SL9WNRn4PZIh;=?Jv zIES{i{W(`wJ9-#oGw^UcIzq6C>-cQus=M{*kFJr?vy;x$!kg0NM=Xj`wxUL^Vw4^m zzi&Wn@z%TM1mp}}`{=E)(NP2f5$zrS8J5R^G#3I(ERHn~O&i%28j4N* zA{=Q`^wNJ~c=)fINSV$$V2f|6$GtMI`W*dAtxYQR_)cpR z%3KZ$4gD7Jj5`EseVJOA(zhhT{5UbM5D%H*IpSIl5ZuBr^NA31ygDJ%{-6E*eULJh z*#OAVLloqlO-x(^l1K2W1dDNCvH(Hh{Rr#jX|2(v>H?InoN*5`>8W3)6aB=Ao?S6F zmjk$Ou>pBzmbq<06)-=1DK(uP;~0-jA-uY)(=kfJF#PR}W@r$5Q2I416p(n;*1dH< zf%OFkRg=%jZlOwAQ&V}DS*2_wbqR9q;B9kr^CRjPd?_0%PTdR6(DCuY75?C@aNJ%x zFnjXylRpzOaR0R$w$b-QGw&VQ%c}2|rcbeFn$n5{c2&VtD8fI=FyA~aABSOgfbn`< zUCewcEmbMd2$VFLY13V4j*++wnRZz44y2erI)fwb-MPV|U-R3nGFiFMia(B0JPi0> z#ulS0)21ZdiF0Ad&Esw1yIFdvu}L+|Jg*Iw|76|0Mxo*l`^M4=sw+MpFEcGh^|PQsE;zQxRZL!&EVhGF?J5mYD`g{ugYD5JDpB;7ytap z%#JN+;iEe-{L)%Ti)!@plFzKARIn(O#bk(AJWMPHYZnXSj^oM5` zVgsJ;?iKpAoU|k&^d2fhp`oGXaQpy(Ae&Sjcxiw9_yK@@%ey0$LsgY~|1KR}p=*qj z7;XLLpU6V%B%YYm8e~O91<>u{V&K8tIJ2wwl(e-i;3?IWmCjp#bPE&xITK0V2feR6 ziHPw{k%v)7%HJp52or_WO9XCD!bh_r!kfGmD(M+O@z2 z{wm-p(w^ajKnIjd8@m9FQWZgxSP&Bo< zEMa(8Zji~6cbiE>O?5S;&1ROvIIux~Z$Br+$6Y@fSTr^*68uzSgyh2H_ z-fs}l2y;k_83_f`yl2|79+S9&XeVGhDoh^@iU3EVQibu=Kb%lC!spzWWXdeq`6JNZGT~H+;BHon||}Mcu_>%uuBv*}D_E zb9NDt>(!b8$i*54!^PtXhaoCaKAFpoO?wXdK=8G|0Ng|}lnP|jL)K#u%1NQ2qWT5{ zcStr4NS=^|;Xvl&HvFmBfs!0l8yJk}yEs?HhD)Bo;v0q#=T@(KjElzqe0~n&jG3)zdfRTf%U*aOg5sl!=ohPb7P^L{^ zvyq&wSek*G4J#Y_OJ?b+mdvrB|4N zCv!PU?zN&~_%%<4$FW8~p^f@>=y61Fzh!{;W6xJl`;(+qqfDQ_-=GiwY}iddnv#)9 zN&vBn*CysiFnRfXp;$2fEU!}@aeTQ*GC=LWnW^h1QAkVYm`Z;@&C$}Xw7V?+cu#y) z@4dIQwDjEF{eT2zH(n)+C8egROXKu=F5;6?<--!V}={T09PvW@K7%f;cYoL_*c1^zhe9>S?ymvSUJ9i>s zzBr@S3jj{Gml!t@S5Yojz;SNu$`C-(fPheZfgr;SutC^W)1g5~p)6ze?JUmDn-Gsl4y|z>kVT34725>r6&U*hcN^&7xX{nhA&zTp0t`U=1zY^<<(%Es)zuuVq+VH+YEk?lW64(# z&?OnOtvQ)K7FyHr_!S^0T93+C*Vjee*Zf^h8m5!5vdbM#8eNx#qpS7u*Vb&(`Vc+l z8YO_A;(}GqC>bV7Ors-{r+%7{0GmWyt-mB>l4BtN(~l+r%Phn5kD>|$?Rb$uAb4+h zboa!w2XpBzd8iDQAs04NSGKmYe8#@!JnyS+5F)(RC?V?YqGLO4PfsDz{2}Yo5hMpu zYkzF)HYlslb4($*iYV%-3v@12?=_%p{8)1xUXBjj!z6f38pAF zPUkn`V1Z@|ofRV$(de+&@>c5i`?KIkd*{xL_tHJ)RVHIHmyrJ&jR0swm?1MRj$sC) z|J-l39{Ls-*vZp&jLk-UkMXRJVV~=}jQXqg%N&pz?!x15tS4e4OKsr@pr#%|w`DOa zRt9ySfU$CE)G2?31(Be@+0w2F%-=%MN47SIsOazI&R$}hB?*?B*{#p7y$qd$j*iX% zBk`%iZS5a+f=NluyGN?ZhWp{!c@N--t;S^rI!#T@*Me!Nxw+|PPv4Am$J_t8psI#m zfeTA9NJc&XxyeKW3|^IAQ-cd>lxW%7uD>TPGj30)t?j)NS~-aUcBk<1p1$Y*@5PV5 zdHjSWv;qeTm!XFcJjM={2^x4E+{dg-h%((O*DRk!O<^)bW~^VWk~ zRY-pDE#5V-65;fCH$L_~q8PchQ!^k}!WO*dcQoyJH!7V&i7f0n#c^h4W|Hw`SmUz; zJ1JTD+Q{dfl*d9g9}5c$K|y%wmRwkJ64Oi_e|Y!;P`31^uHYAiE$J$(x9Gbcy=quN zP%W(o{F^)@)wq5Vfffob_Wroh>2@TmqGQX>Qk{MEm3xdU0f~FGUw#)@6UQGTn;=y0ct|hI$L{M@XwDY#fL5R!ca}Uv5oc9 zq3LNr^oXEN6E*F`Jb6pkfyZ&L|CE#wMB)T=FC4yYg(#xrZ~~dKD(+uL_^CCQ)%cTw zy16CAr5g+}%}q_w6rh_Y3ufgb%Cvpt{jW!R3icVp^HddMfP87!PpVkfe!Kiiz!&+G zrgr(_Kcgg9AA9DDCg5webuag1YSXJ?*-knND{j7U~C zS>N0H`?;>~=ej=sz~}3_s`Epa^LpL){d_(j>yFgcR3;~7AcY`^9Im3E3;sR0{6m5d z{EGRQRxZ{Q*NE9`ljpuW(+!6NpU=;T!^s)H+lawYLrRhZWr$h}=eLP0 zzqgLh3w=(`PguHpdXiv0<|;nB{q+Uvsd73ra1;tAwKsGc(!{^xnclH7Xf_mJ6Us`R z4neGJ6bZ0=-TQQW{-5SF-m!GA?i?I^OA10@1J69hO`L=nuovei&CSg7km1claas{UA*Pfygar)K(ZOHQFVG zqxe$nj(1+3ITTOF$JB!d$i}U{Q>E3w5z1=Q+>fGe7pSCvYHBL&{(ERRS2<|AlWyGD zEk-G~sH8+VRUKj_k>jMn4-O-yEHybff6O?+zMW@#udT>WyT_A7n1LMPq#=c<2t!~! z4Q?w&W#3=+X8y5O8+>2%q>z@D*4on2ezL}<s&%G*$ruCqNjs$s4GoQ@rKLZ37D1~%GD3VBMOS0vGJ{;RpI~90 zK7C3@La4_@3lWCl$x^|t#@?1#-u0<>nq!W9h1RroaM)*kkB;|lYSCTsP__JQ77`H= zXQGH*#19FUlTCyjuNG!$W{6&gzRU|~JRxvc5nJ1kId@@$-e3qJ(${iSuO73;8?)YA zH1*rq2V1tcx0l5I)Yxz12z$QYvWjy$JUNpbRK9yX3bXF%XKQO~Vq)_0W&Yrb9TI7X z4Q%0zV(RK#^mlL=OP!kF{;hfFsoU!wjwqY=VLsEfNh>ZX(U6`r3k>>G^b-19#iLPg zbh3Wo{&sfLbdwPJv>VQq(QouhZ!8U@?yW)%Ik5npn%Dm)fu`>aSyJ^_YYnaOxXWB zn2JHcu7>{fY|0%*N;1#YtTqkj@=sO$^3!<-YCCbT<#^dl`r?!{vFEghBP&~6F-QBa z|LOV3%0d55<`8=t1#ORYR)Dzg-pgCJZp~!zz^P@aI2Kee@^({3ZqrY!`Wqkd@=8fa z^e%2UAI>&CDNt7&@n6iU$V7b7n1di~wDjU5{yr+m!c2ymjA+cZ7M#qA3NO+1okBsbm zWpvWanne#`^^c#_>$@>oKTO@(y;kQqGdVKCLG)lbD2rT7}kgBaxfYIp}$3LWm?Z9&{Q3da|#t zuhEqwYN;WVoqNT2i9rea{vyOm$GWy@lO<(aR9sB0m{YK9xxW45h1PF(aY(MrAn0g5 zLOz0`cEWxfoYyXvAj!bfqj&G#rKK@IqW(ui;FW!(nPa|kaS_!aY!W|lM)mmVa}~5_ zI#LlPU_Kb~=<4Y72#~dZ*xlXD9sZpoAED`2-mW8Lsbb~m$fq$bNQRsLFsJoU-MC~- z`uu1yLB)Ol58Y#&e6FVJ_E{F*W0GvwRg`I#q(BC*6Da3Zckw99zHiHfJq6)uC4B@% z4h=B`e!oE9LT7XwvlJ1Opt6M1l1YHARyCbZ&)d?{DnmbWBA9)4dJ5^4%JjIdh6*q| zQPhEvgccOte^OvSRoB0Dm}~kF0_VNvfy+YwT)^RN$bWzAAG5S~)@t)jW%tR@zmgT& zoS^o*7Q6Xr6!AV&%ACfd(Yz!ULJUQvrIz$o9dG6bC3n|RY+CLfTYg*Ud+=<7k|b3& z4IH8@8P)>|W}|MZAP0ZmS(J%FIajB}v{1K5d=dvar`5IGz9tg`eg0-va>I|=XGy0P zkvqO-=C?6+yt|SJV~fIvqjr~P#-rB`GTROhLa)R@5L`Eu73Qf+L;jhKPM+c^SJGC? z;pX0IJwb546=T2(ccJOWzzgDQcWQrS)!(F}TigE!dQA2xsRoWS+tz-`)zww|xekp_ z<{UOiMlr$f;N5Le=buA4XHstZGV$@=9JMMtbZxu%w!P5jRd+<0LFS52W+7)3D~S%x zhUe73{WWk#??-X*@x?0T!r%-sUkfu4->TLziq{e={0F7n+{I_&@1%l`{G;ln+95k8 zmbJG^x^{&ut8V%10ulK0ZuLa7%i>oxz6Q(g_`ZOXgZzSmflMjCna2GWU*G1~M8S%L z?Mzc;f@W{cmM{|Grs}63ig9o}%h4tZYh!e3-u~G}pp=`i;sdAF=X!}jXoN3*dggRS z4(S!~RoJd>%F5-$b4I;<`O@iixaSY$*2SgbEDR?F3_(sC?1Io^>=Jv1RPinM4tRUi`$7<DlZ#@wIo(6DwEC_XMyL8I#IrLGk6I58<@-^6 zgM)O0cWy>|;0C9#L0HGV)hA5*Coy$tti5(q()&*&rR3kHw{OYdD4v^YoHQ6tRVuXg4?uI5OvjfLtt)*@6QCklem&&OPr8un9oODVrZKOirxsTo9*Ni)1%MK-Ds<@~v z0B!!xhe77`bl*ouzmMIfRoqT~=D0dqCw@TJIE?-MZKvX?R@3t~;vT=MswxVmA6u>s zd%kCrwRUXq(&9fq0{nl595*!hsOB$BIbm-L{~0+wJ*}#GSUpi{P$C0*PP${`RR}Z8 z>01+6XfhMO?wGmKB(tkPaZN!73f^Aq%57$bz=F}yRwBy4602%xm^8Xyh2AmEe?-CGzPZ*sucpoPjTWy{d+DWkc20S)m1STS#Ir3mkcBk|BS{DVE?FH* z)u=nnagT(AiuKfeeG&5VFN7H2l03eInJZvQi!wRd+pm}5NzGgG###l+QclloQ`g`_ z;S{$G4C)v+Qbk=BmiyC_RP%@5`<@=Tlo?0_oy#8|`#d#v$?vo{-|k|eWRV_P%_qU^ z=Cp!$!2X-yhTy_pbcikO=Uzvjytlt}M$^p(97^E|W~@Y>wVwZ5D=BY`Vl^MkY^kL8 z>gB~A9Q%Lxwj}B}Ry-Cb>6&(|8}P!p*`a=l_hvP#yX%>z1VQ1iwJ~%@ycd?dGUx)E zua;%NNkb@`)_)U@!VG_q%b`+yz#QkrIX60*k&xgrta$Iw8k*C8uGz;;L&L*$xiZM+ zX*9>9>=rpxUVFq8TUk_eG~VNqF)kX?J(#d?EqEQE$%j5Hnj833b{I7 z`8%;0d?_Qc(~!CN&bMz#Ft~1;dG{8jz}v|UPrnvZpg(z-Mn;6P0%Wn0?G|fy&p^H@D;^yY!ij~~$8!?p$;upKWok=F;4qegut&sc@?A${9 zn5_mM9F@lz#T=LN@&!>?WnEoCdT;B+xtaAqGXZ&khtuPTR`v{%)?p>$q+Q8Dm%F`H z-HX00%n$;B7{THAKm8014=2j1poLnGm(!g^YXVN7@Wrrohm?{RkT$}16#(dzBjm$; zRqDJiAv2YpyW?H~tz4BcHM2=WEv8#wrlF7MBNfup({0kqi;5afcY53Bhro#U+?qAf z*Prc;*d%UDwfXTR9{23ovqZ1*XZ3uQF<-hOZ#W&Cobi<4LGVywmeUzE{+9RtQHZje zijk~u006%D8FskR?+5ia!bt8OudRn)jXOOvD+x?L`jJyHF*1S=@$|BjIy2RSxv{>! z?kuXOuWw=`MxR7V)>z*@-yM2|vcmMNP$6}#0;`$({6C9?;e0JO{C&*MEMG`}EA&Q{ zW~{EbyZG8+P`t43WK2gu(8&}cuB(05-{<%`c8>D#E4en0HR+&*$j+XWfYWWZiHV6p znTzS%IKI0ia#V!SY6$V>R<%}RVgI<-w9C_qf7Pn_x+oY;9Y#F2q2cNkQ=XD5(7UUl zH}17V@yS*WwzdXbNo4KbEv6|Y+wGL&_>Z4I@5xUw6}RlB3cCaTE1+3VpjUFTHwfWW z3NY%cuR}{N4ho<2i`L%Ma<=8mcib?+aHgrVH_e<6NL(<+eAUjc@;`RFIKf^BI?YN( zVQ5I%;3z`8!`Z2e32xB`@SwjfGb1AaUT@(A&zK6iE_F{&8`q}cLHW9`7lhIZY|;ui zcTSRs)@qPMPd%U#Y>}O2aI0M{cL$f1)v*q9}Hjq zN&ql`H*KE_j?yhsO`E)ereJ4H^2&9BKFnRT^#{FYcTCMUD9Q43ypq{vaq?%5R3YLHgoA#PH|@@3vxfPOuU+gByMBdm?}pV8samdR_BRWq$s>T7+kNIQdgkA2y9Ad?{-kxA7qSkk1MClr`NN1Yi7ZLHir1sYMSX z-zw!Ic%uu(2_Ow_RBa{{DUq|a_oY`!ece)sI3X#CE$Z$!=WBqEHp}JkaC5tfGO2v$ z<>j@pvH9>fgn$_O3C7;pa(YEmqd9hnecM}dz=bL5$&ExSUqyJ&P*v4TK<@IzI3!5w0tsEcrL{M^%S)KQO>*VX&)KB4M*Ny?|RDW ze{?Y((X~YjrhNU>uR~(ZeNO@UQKV3gwjou=yLU7+r^s4FDC@&!M#i_}nZ8}+;%Qg^ z90q+h5#aUMl5aDQFR+1AvlW@hh%(0k-gQq

`O?adPjrtqyUBNM!DM>D-pGJQ;?Q z-K)2en9az}R`cKRB_q@ce}4Wq!kWd$^0~acyuQA^hDOYcW0rJ4BhDzxzp-7%Qsh~m zl^|TVtgMU>Pc@&51~c>qG}^4de~}aRb%v!ymG2Eo6ou#NPl{O+WGZQ($A_+QN-v;b zgHHAz4Q@am$z&y=rTs;ak|LGVw(Tj&ob}+v*Ow_K$^O2wA@YT|{Jgx5MVDGcj0Iy@ z+h-RhI7&#v3z2ZxA@K6o+<422G_e~nxUGW&IU#;1D`8mMqvHOk zqG|Ih1+lKKF7%j2F|5t`k4>aBDU_W&U@x*Y@q`GVHjTIKw_=oZ-Z3yTir61njpV`2 z0`|w)QjA^ug%>k4t?xdutEam~77ltfSdrJE?C@_$q3o+!poit_Mq)mUufgE|_HcvH z<3e0c@onB4gu(nl(hxMHM?RE4eUFhY5tf>q{8gauU}I`nGAG3GuGSTgV4|)mv%rQ- zSW&8=^#{FTWo92vm)hs;d|bLT=WOmI_PqLfe+c`V3AyDC znHz~PQRFnfVkOC{NMa@VPYK)F*&S}qJhBar{AHFo2CLJSNfgbF%@@r1JP zU=OHBZqm|%vErBlE0TNA$i3@%wtok$S^uJ|PSB;3bx3m|SIOjiySkCDuQ-F(M?@KC zr~=6(ToH@CK?s_s z4hX(+_j`8O`ef3zz?`Vqaq z6GvBvq9PjHr08;t* zn*5Z>*rRQ4Ci0I(W43UV3W|&KLp| zCbyM=#1!_%lBd3!&!1m~qu3LRYHDaUDktn2VkTF1(Y+N1Yc!+onZ2`0G&hsq_p?JP zBr{gkMPq*qc`m=_(^5r{4PErgFWS9%Y6kLa9F4LJG3@v5N%_F4SkR93bO|tgowy1R_c@Gbss(?09edwxnbv zDL66$O}!a)3=KnPYkhjyQnC}w(VEJ*QJ);wrHwkPgsmH898fS)2p>{ZR1`;>Qgwv< zJxaJ20r0RVs6|jTH56P7F_D;~<#ZtGwh+DB{WA3iU?1d2^x#TKaj|QIameG~mDju7 zD-N}vChRv_nJ^_jr^u>zoKZG5-+7;j9HzLSbqx(OQd5(F+7yN9x5C*Pl^sZyJRP{_ zbdU5}^wrRsDF<=1G#G!pyc+qs#bXQ=<4wrndtv5}=a6(C-m51&Uh|^76SZJI@R*Z_ z2M&I;vF7HdBV3{+K#3VvU0P<2$C{$wNi&}(D+IOT-SDZvv zpzo@YNV#1+ZcxIBe6aCF^*2PB6jxVoi98F@ zEF=z|o@PobxTQo6RT{dVpArw$g*bG}EDjKL|mp~XC`B6gI zuNFEj(pNM~HCK+08U(EFFh&vtx9g}=5%yNyzkfe9HI+T_DlbBQ&3CiO>0mf;zJm*S0V5X|MQ#k>EWy6*k(mn4Cfm6$sL=9C{yV;9OHF~UxIGrC()M*;jdK0f7U`k7QYJejA z?VG2kSs+!se)b5tr=Ly)cEIIkLS@IG>|~7NxA$(!bMLD|PuXIRDgU=xYnQ%YTSj&F zMI3eJlbj@o}IowCTJbk@w^P$|yB|)4^PeAcyR}Hv=iA_ZOno-w69m zEStw@#}9N?Dj2ZHJ|c&|gtX?8HMcm=-%X0r;i3gfkNPqIKok+Qq*vf5NWkx4T_auO z*k3wEi2w@3n&s=p&_7rJbpu!+9pOC7#SwBy-67E%GpVvLD)qs#Dem_z8qzC(o<2q` zO*ib88mv{l)Y`Akx9$Um79a&YC6}HZANG_ql(o)bDjD>#hpC}$tiFVBl&R0JF`wOK ziVtinDfKD5&jtjzpr4;U!Qh(K0Z*P(_Z!4#W)AG0rU}~t6L(?v*gJC^DFlOmN2ezzRdF?%h4L?f zm>dmH47>_mQrYs5y4~qL&%joPfsBSrOIC>CmnfeLCv_yO4tsVmbw~c8ax9aPi3$3L z77C`Q1JF~Y;oj$vFu;GeW~HFN6V?}owSVHQkw0#Y_>6o^L=*^)+L90Ms^|-8jE#-4 zvuw~|D-5?emRisD_^BnL?=lXDbE-mA6cJtqXPa+>BAWtRvt=wLbT)?G^08drPntspYk#=YRg^w`22R#|3s&4g|r+yDYjzdJHjX zu=_eR>g>BE6|KH)SK7hisH&>Y`mCUVu$os`xVG<`xiTCXX=df-7HwLwHKyUGGL;*J zdEfjr-$HzS6+ra+QBj?D0bv9#;9GIP5*T8b#od3`tcWuywnxM{HyCe=di@!G%=+0S zL$lCty|SxLNy`bonYYr5_m!7^i|EI{PW9MD*j2)iilU-F=N-VyN<*W8!K2-DAC6MW zE#R~r$net&8lQEmYiWBM%P`nX{UF%0nq$^!UOX%_-^36w zLYFoMC6(aSHEhVioQpU$6p}!f*Ij04S0H3e#T-YS9k?}+$O20Ge7~6co+LjDOhlvQaqgl_%#R$6 z-P{DBzmjtr#(Q)3`44~PDDj6^&|M1!ElveRN_Z+CP6lESFHQ~|Kx_r{uD6{QkrY~Y zmjjRHU;nnl*dVBmLg?SA5OSeqKC+VZw7%)I07(pc7&W}ZEmG;GepKX*IdHzY%Ri0G ze{^FyTuRj9H9Hp)7y2K@VhrEH;mN%Fj1NcysZ;q-K!GjxFCKL^oAQ4v0s2_@a|NBz z)tboMM2?GP&@7o{TFX}ccs6lpWlj$b{d8?$WMuS_Zhg^pfJ2NtBI=)7{AyxXJrUMs z4xA)4wQAj>stNm$$G{6=U%_iyYM7NuVI=`Q9w;JIB%Dc6V8$C5B+H!s4PAQJn=C~D zX|8Mo!F_K}D0vK!=%lFYiXn>&Elp;F$da1PrY*}>qW^e_gs$$y)G?xpI>^U%8%`GTAwpS4pVD#)6 zIppaFICLm08H+T&2Ax<^eHlEpOCQRIUL-CXdtJ|m9i3Jt7A5&@rlL_6fS*Wu|IG$g zP+#Ba>F%IXT&sGH_WD%)puNb-@^b6;SH}8TUv2>UHV#ib#5ZtS{2L+n{1lpO3_m!k zczR(5r)W7`4Oye`p|>nD(hm$jS`V7)b=cO%GKm3w%wu(g4HB>&{SD+KZB9MYKti#W z;$mJ3ZqAQpU{0XxGSPod#(PCDF_sM+_dt^GxBDd6CxDTFrMSGj466M4J*D+IYy|)c zE^BSp&dmWhjf+8Z0cNq0Wm`x^p#{*l4b_DW?>yzW-INB3GO?5I)~#29CAON^Cakk^ zIe|CuE*7A0(4`1vKb98cG3>iEU327##an1d zAF~xH=ia%RL;%UgV-byaj(^Ks9BaLMazjxEl~-JxxZi(`zw}l0x1Ziq^Uj7mRWmbRzazPazAozn$~u4c>sYrdMy{Ci}ydBz!e-9^-QUw3WZ7|X;)Lv z;~CGXjn}VV0}6WyvH#AuU&RNeOYq)SP>|PR%%e-(-epngv%9%A`&Jsb60u#2g}zOg z1%Mi&Z?J&#UaUQ(c)#%8_5A5=+fK7(aq1JX*u`US7`3dNGH>ME7UgTXHuJx~hjWF6 zg@65uaBT6|`5H5)o)sve@kC4J3++<+m zq>hK(kHT~) zZiPH&{(T9( z_PtEaKKHN7LPz>ppiHkV^ceNCjX3CVM2(;Q8x75mLiJRxsJlQc#Jd@-NTVoAMJ-Ql zk`V9b>apqB#0Ix8I;b5tMT@x=u)dKT1d^U5*S}vte}_n3%M}(x1W0G83Twk%=-r(n zWI1rxp3|(X=x`Zxsl5=>f$M37oRAhvjiN+QbrP=ts46To;ZyQeilI>XXZO zL1AHkhEErGlVgw+G!5AQ1QI@EMBw+1ub~C44~BEQAgC-Jpb)$9^I9h>_s$+eCVlDv z0=S^hU>-bCTRwwCa%7|wWK)>q==y8aUxfj+EycuhVzj6}N3upW|8k%ZzLv|;dk&70RgDg_MH9-BYtbD*k zL2mq&Y9PEG#3{9Vnwvv`$MkD?nFt@R7NJ2!$bi6yyz6(_R7`eYpCdWhbb=}}EgIL> zLIH&&JZl8-^q1MW>xeQ-fO{wH(+^{|SQ)BF1!KNLjYl(=+Azqco(5va*F2hL$jQ5i zEz*X18b|{UBrcu6VZB$NYU`(ZdwXX#*>i`7fC*_9)Mxn+1j`V5rOl^94)rt4(VfUN zr8!~@hlbD7kfQ-~6L8~Z9Or87I0kn^f>|Ig?h+V0&!#aa=MFlN-^FEi+M$G0#nfMOA%XbR=QQrmsaC(R}OGcx^ z0-ea(VXYmYTXqy@fm|1fzlq5PaLPa{cgGcMp%U@VG%t<6Y()R($l$FS_k5X5r!~3! z0)|V6?4N(@^0Ji<4FHJx`e0Ulw861s=PpLnN)pT2!`tUOhR`s0_kMugAlA=?R53wV zc#Jc;*>huV=rq6l)O!W(MMv|a)Bv~*Ih>?Yx4JsT-x}@R3)|XQs#Zb0Rk|1YxkL{@ zSsKOb3Qe~Tq?Y)5gFLztvt&;;tyrl5)?n%aI;Skvkb20H@C4rqtf+Zh1_XPxS;Dq?9XLq~`4$NnuF88ND z0@;t|nwmXeV~`SsP1QTGNO-PCK4m5QxpoOQG&CHsGDB@sZRWuk&KRZkUEolv=8u>Q zkjSYN;&_`5>*lbP2bMb4)(ta#xDG)iLp3XjX@%Fy0Qv^II?~qFPm=dpoxjWwPDeby ztYy*1l!_2LH#a%S5UHR!)uX0_Qh-zcm#-Iv=@tNwcnUAl6BZHzynAD+ehw)lz0>_g zV)K2ff8&g4o*tKc4zP9iPRTzKa}wb7;TQUN)(kmDZDGsl(|g zIx~MU87wO>tjd6($q>q_$ z?D<;k5^m$*i+2X-=G_Es<^ohCz{E2b2zJ%*nsGkv8Tm7?OvJW&4z~5Ri1xdx!OTsLN0rZzlz4gZ=eb|$n(P-fBw$h-1p{&;QmC)*j=)M0HyXKdQsX(=fHCf%A0 z+p7fq6Q|c^@!m~lIkyj)tf!mv;eMVCVj1)lz~Elyn~|0 zTZ_f*y0+_S21owVv5O#giW?gGTPQ<5I(7U1`hI~MleJ!ew-xL2OYW#lVS2uO+ll7m zQoQXOg#l?i(+WzWu*=T0!1we8u$>bhsrBPqbCcbgbmT0@72k%S8pH;$6OH*&fV0M6 zdKe{w197;y)#t$U3w-x`PC*>MY*eLYnWs0rJqIcDO@K_6O4^KDmddJnV#9On`}fV6 zCMV!@0z3%;MP6P_O)Y|g_d#l(zJ5}PLD|QtYyDPgN=iziOa>)m$nP?wuy?92U%Uuw z*8ve1U7F6_QxNa_R>f0nr*nfJuFLvUt0~9fjxVBJW=KgxwPlCjX z%^p+OCmw;AbrbLOcLw}m7#x`B8W$QAhF9?=nJIns2^i3TwgGJbiih5Ga4|y8qET>E zGth-VU~k3|th|KRCdd*xA(5=`hYT@y3xNgFX)ofZsYwCl<^9HcqXKJw*-MFhSgczl z7kMSF>joLoa7`W%ix?Of;3xnlq}+f1%uk8EN!YgG`AE2-eV)Ykm6V_7eiox2vFkr` z-=^%(KpPmd4afYx#^0=s%KQ3N37jkvIaaE8*i{WDpf)g3to-@~6s@KiKKMvb(7A&| zI*NPiU|tk;)KK8`n{`_!mrp{Na_s)<>MC-&&9)Xpal_R`Ny%43gB;u&fKzp}g%K<0 zR2lXG(JK8>kk=m%rr5hRlUG{yOFn)SV2%U1TF@73$Wbtm?GFrW?KfbJLiK>Ga+>O; zJ$G@4#S#R2OU5S4-Im$3w|6)37~}skuVQDn26TB95NUvemVz2k)%?gEe`$e2Kb>iE z$U|9UE}amD!QBKELN@XZ&y!@{XJp~;rl%z#f%JYB#|hl-O51Zd8LD?&ZHY z4k0Ewh$$fGn%MVL?3^#BNJ?Sh;^A>=U=WLy@ZI}8YW9{83T0jQl<2L(a1NxpX|Zg2 zd)i#nBfX}EI@HYYPMn$nF*DEKldDknFw#3ynP7{jH|Y(s*(>?dFA@ zws3x|Dbv-0&i?q3u*et%i&08UO1k3E%h=EvX4?{hHS#TvAG^nli=Q=%z-P zf&&tikMC{L|7IqVMC86NSOu43a-XfNtbo>lf=MzDvNqdQn}ILjZ;kWjF_YY3h1`TMUodL?w>?h1!=^R@ zFV4J9dWS4g`sL$m)m2sW;m_&yB?kZ>$q4!M;vzI7u+^I}B=lx2!l4$iyR+jie!I8I zsyem62D;iJe+=-?!@yH=U|;WlP8}u|SDBu4m=|eGJB^r42O%Fsnn!PP|C#0Ul^+|5 z;1*7H_GgRAk31#gYv7U(qKqhhv&HLs@a(@nu{+zlU#CdY`(;sjs!_A%0t{qCEv6M? zwnoWgYaWQQ*aDl_=zyIb4(xHiReG#_@9XPJF*6lJvhPzZ?d|qX^#HSvP_|gF7_iz}1UexB zz+n*8TDXlBNAoeq<$Cc`On>{#JaUIWu<9$lpq2Wgime3Lb}3~+Hcdsl&XQ*nxPZ`N z>ZGZu39i(rszw>~j?{p=IN{-6Q_RlY1n6NT7TXK6EjdovV%TQ1`rQUoLqiMTC0^=M z4mLL1$Nodfr}=oS_wKcncy*FVJ5bB8DC<(oall7FBHmsk`6cg75MhYyItEn000j%z z5hUCFx8~HJ89>n~u##IZp=}yS0)awMdP|>!TLn__R~KU)o#DtwRB#)cRqOFDgb$CS z9bUigWFUWLY+R~aL`EbpKrh4)1I1@$#qCgXCE0FH!4)+X%H_;M{||zACVBt> diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/newton-newton_renderer-rgba.png index eb8542490adc5608f68da8c14ddc3e09bb433ef3..4e08fb2bbbc1445275c27ec2c9a39115c530c568 100644 GIT binary patch literal 10177 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 GAN~(du#;f` diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-albedo.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-albedo.png old mode 100755 new mode 100644 index c45e91b1261677be6ad451ccd0133c0384763ceb..ecf5eb5becf58be32be05ac8a780a0b2310d9e57 GIT binary patch literal 1907 zcmb7@`#0MO8piEt>9nh7i^?j(x-L_c7Ne67egSdoR{R`&IbI$ubzdYx>&o3Wp2-;8Q@QK43 z8X7uCfB1#{RDHQaU+w$9lfX(14ehf?xDO`%At><*Tt?{&lyh?jeVX)6o+Rto9yFrp zaSY5ai(gKwgT=GDO1d+RB} zP(`W9^W6Ope?|q7{+*UcXZ82Jb4`q*k4HvTK!vU&pI%}DsG}QtPBUk7*p%(zZD^5Y zQ~KAT`D~*pN2NYj4HvoK=&c1j0uwL8PTGvb_0rs+`3*RE#c;x0AZ$c;FO;0w-I3yM zP{-CZGE+nr{2)FXm_|dSX@j|1YDC4tS3Mlw3kX8I0@^??(`g1sTx7N2JW@fKtM7X-j zzb&}HELU!mPFE(ro+M^rgM))*k4;Vb$H%>*(7`fk6)_ueBJ&(j(-slh@1>1Bzqs9o zvX=M0@`gg^SV!aNDto%I7DM#(AyanIJMX<69A%$AEeUwAjLekGwU=w>K*c0q ztlr!_zbS{cK&IHjv z4#=x<1jzY(UXJ=9$xZJ#&j11$403cdH;2LGs%95c8-14>K-vU<+Ch?)RW{4K>slmC zKT#(pR|@Z4arg{aG9n_BndTjj9KZx?I}%KqdbYW>WpjVh(YDt0W48dHU&7`^ZTv@C z=H2cu{wyepEgbIHeVpBW<+W2XVLo3#{FzOu1{15U9x#!EK36Knjs0PBK-GQg1c{bD zMlsc2TtRP?>5mSrzUAj-TBgzi?P|Ac{X|+V_;$P5RNiRY*9O3Y#owPXI5*#{sCWzi zov8*%h-(cjuRA|0+L10p0C%Z7MJH1@s;uj$jTy8w2dunDw!b6opEYJ7Fwt0D=%{)v2`cRF(;nN*i**I3fT+n6%1nZ8|EChC5V)C%q~nh@RD6E?5V116KgrM`-+LK2;Tuk?Oy=s1&pj+0!P>B-A;1ib@y z{FI>1#*pk1T#o*MFu$TAeSEAAQ4(vWDb$ukEkQClL53NyMCyjoH%`T8rx35)UVs1( zl4tlr3C%Ice)2r_hz)2StViF`(2tR{1#qFjhb;!Jh6|bf*PAq*-wpG=vzQXq?Tmkj z&A9bIc5Uii$((mUA*pvsJV<_FPY`V3Jv=<<*;%QUSnOV$y9b>{>oqX|mX*x%#TbOu zo-t16>C%#OX^D}xW=47egHfqKt+xdX3rW1H3;eAE%Jp@a@{T6Ay|}ykndypR9vlSU zY6@4`JOU(fWp1upl1fso4@R3GA*6*3TsEt(cP=03r@Av;--^2$!XA)ftrVn%&Q8cN zZpSYA-1_D}8H{8K{;wVib47}18iiSo#Np1kb+qT?EH9UcCnv}cL&m;P3Q6N7skeZv zB4-f_JqOe#tWwahpSYVfg>f<0_$$=4+ih%KY3(~ONBD6}IwRem1Y3|!`0cP*Rw+V7 z!n9`Y4__(GA>3G-u$vd#eh%}84f{dd6on(<4o@E~K7j&85|XlxECd6VtS>R~b+0=UM2k+H z;*~1xn+%TlINk8HJ+$Mqe7AS{qK9ii3`*pAFjN{Ynuj`%s``I}vnUQOnIUJMuiatS zx7rb-o~g?WnTrz896X^4l<WG6L0RZDP^(v_Iev&iGW!271@W+ zu%Xr0BWZ_;%!cJV@r6Npnh-zHKwG-?Z3gQLV*BLT4q~xsq3|1`$Ut22hsJv|>}E8^ znb>YM>ifMYmzQogpEUQxDSq9`p-d2xH^wqKPo;H9!i98_rIo{3K}d%_%OsdetCLtf pvI1)ZaAR3!SldypKSb#A_}sa7yIFV zi<$l+dc54s2LP@)g7tL%3C`V|jqu`f6Jpxgo~&*$DY1)>darYdbyZMyAdQ#8Y#7D+ zEGC)(;u&TU9xW2j!OhM;6vqU;%HYDb9Nhd+M#!%7Uv>qKKzBB?eB{%sNIt z#kRUi^jNVhXznucJqa|&btKCDq}OI=JolK8P$+_ZTME`vWAC$cAU)f!RY&iE`l;#0 z;X?;!@9RX5gr{`->dSA|Oci9GUpdosjAU4aN~O8+X^TdKXiDA=bCijJ^lbcyG*Tau z*k{E!88%YCC}UouH=+jW;^<2$YPq*2=UYXKPO1~*=G2GejeH;piO21s8)R-IOoO6i zU(0uHCBM768R`?X)lI04c8~OPkt8I$9#Sa@#@Wwni~FDtd8~+6p)-w!yY7dq=K0BM z?)hMijxlWJ4|%u}HXulfvF1ybGAan;0K z3lJ51H2T14TiN=4S%X-gLL3OGVBz(mX4AbM>qN^CU+cO?lD4Y0`$<_Jp|`I0{@6JZ z(T4PQbRH`PPd$0ndZ#>nMDClM3TlZVBe>T^X%LSP2hSwfG(XNHdF^R3JKTie#}2Sw zTW0ejv(2-uU8P?)o%hGe&-E3wUZ);C2%hOi!Ssxt*E@KkfN zAnR7ashR>cffW_UYzvs?Ee-$Wp-S7DpzS$Fi-j%q;lDkwdEd7ko4eD~D-pTMFUC~o z!;65j`?a8rStRO>y+8n*DWwTlT(MhPcXYJ-8?d;0v^HKwcXqN~6(h1S*D9dh9@(q# z{+rP}i#S}Rx*lF76+gX9DKXB@Ji}C0P7I?s*N3KtGp=(#i=6QxNbI&dT0x_nW=sB+ ze}}363Y$>@2uJKgs+?MvhmqD+Rz;cFnVC25hySz_i~+NK=67YqZ{fvawUT+2baR5g zE55gh9}Dno)H2z^eV)b_H4zVd6vj)MT2Hr{0ig$fE-mz@i2*KOz4?Hr~MhhOO5??;>_1zuH`Mb)Yk=%6+1bA;yL?Qa8`2I?A85>1U3{SHN z9A_+UJ;zCQ%)WruVX;!&KO6m5E^5&!tE%$wtoL!N_Nu-ksbnQ4;)9k(Jv=@8k_6z# zWYWmU$jQk`Tg0ghsmv_TsPRlHFovof5DbhEGbrPZClj0N2F2k1A!qv6rz1+KV{TAQ1j)w1mellCPgQ<&&d zv%Ek5pksyzz9nA$(4h}>PnY`Bz7KLrngMLu!W%`HDYNkTo&+AwI6W8)?TsELFXsYb zDL&HPEkpH7DAHXZ5NMvOc5BctEbR2+U98b|B_kfX83XaoOBUD+ub*G&v>hGu$d;7A zYf%@bB@1dkwpQc(I}fFeIP>X=;7KxQr=6@Ly_>*tIl}qd&mv+5ex@20K}c_QcDk;y z#wfm`)qXlypQ=KmHzz(@!RJECe64D_ZF_Yo7#->{VE~!X2;E5}1@5i+jki9VuJxQs zz4ahylTd=V!mqr#iZ5FBljNb7hjuy>)Nxa$OMk1)_UpF=7z02~J zP%|FM_uG$FFX<8=f60}R$UCsUX=E^k}f__;%Hp>{;nr` zO3^2P|MEiPiXvRz0w|_$Xt7Q&e}(;W14JV02|nnJ(xCq$)#~%rx9Ep*cE>5}y0%QgZ-Ce!(nW}4yW4aJzj zWx)^AS_tk#F1Q$h0P{wWa^F!8H@h;WBkv2ZddEXUz-jAqU6Ja%7~Z5 z^!NxQ|1oVqGdsIJjZvrfL%wPzYYcnGnL$J3j+~>!gDFI;D1jvdg?a$&9P-MtM>hyu zH?JQKZv`rbZAJ8()`g!0ytMa5?@*8YgaU>iaKYY&yk!tNw4l9-tZOInO^n6@3GrTk zZVpAo9)NWJa(r9+&Hh;Nc4+rD%TMmZ8(N7MH?hE9?fg`ro+7+*+cm8-y6NOvL_F|< zGOUn6j+11TOVP%lAG%y>XUQ@DFRcDA$x>L+Nb9J`1edQLG~~eP6{qpM@SL=n!0%!0 zpWUJUUob+?3f=}9B+V|MQBpSp%5uF=C_4V!gtb_QGO~RB%B(%me+wR7HJjTgCnFzok9dIA*b)1;Jm}m@% zU?qQQXvlc!L_tuosWKVV1G^mh`j_r_%wsHSTt}etWvU@tABb*s?^L3t*kmZ@wJEZt z{O$J@u6p79b~Q*+&TfvG*X?|8y6fZz@hD(N)>gLG{$BR8kjKq0b)p3X9_2v4P|wV! z?h@Nyy|T5P7E8J3;*8W0TD%Lj+_RB=zSm$`Deq3>qU+KA%Yq&dd@EMYcy{9-+?#cD z0|);s-OnqOS~U97jkT3F>fsCybbc=ifw-Z=&d<+pkRpU0(g-0hz4?o??PxKf^EK1V zXYHqaMVph7LGf!2<6J!Y5J78v#3Auv01p#e-~{0U*J+FW@SS9gg{tf$8=GL-*|ASM zHR$YU*BaxXgVHhBtSx1V@WmBwF2PM~lS`z&nqS?am$gay0hkN^odnH~xKYYON$#7U zO^!!!ctb(ey=SzuJsh2$=yhAfld#RZfV{jseSLk(Tv%25yjJMyX`E3*QrY0np-n0( zaDVYm(JHB?H=g(cW6+MPM*Fiy0F-VWSk$z zDXF5Of+`ygU~BwMM}g|~6m+S%%7&g(DRd@z8jnIgFH;tK5-KVxSXnG{YwtL5uIwth zOjh^%0K|r#U!)?kvPzKaPN}Wyop_QtiErpigM(U9-=4VFQ=DLvpj8iKCkB2xqQjdP8eNz`QryS7nccAWteukhG$Y?$12%5EBikI$AA3zVPEe}-R&zzw4qQ&?RjTL zq(oW0Qv-lDeFH(O;FMu_UV89R+QZ_;Ms<=CF-(kJsnEquO+Pa+MW?tX;`H#sin_Ww z8hKEm$#!mjChm*A{TT7Xi|CWvu-T@-iy!v=eY{34$JS}j@U+QpV4>P%kRbo4qV0!V z4E>fqC;(rVLGn~xjFW6Cy`*DdNgPB-RK9+9xF^uTy33hdo-U*6h1BL?hxyd@6B$OG^D=W`y&@^u?wwx*H%(254KrYP zcvxsfHD(m@wPw91p@&Hr>%_FzC-M`5_t8U=)+e0@(y%P~%wsOxT#6_VUmL{%k z4cf$uw&2aalWU+#uFQ`sI}#Lb%ek6F0BpqI^Df9(>5tVf_BUMRz?RR7!YQ*STTKJ{$P5}+1zh2gS$_M@qx3N9SE#m9|z&?BX= z)BsZsAS?CECa9QH-|Se$tA#3mUP{&+ohY_PQ8@}B9ee$!pu+Uzn%RIKEg|1pLXv&% znhb&dT64kNH&?!*hC;%3o=)kBM(fe1F^ft^UOnuUa{a^xyYa`%P{hucQ)qr*GpWPC z-p%R3;5_3%WbVD!er3)Yx8Zuzua*#F1EXX1)L}ll^Xj@n!Ghz?`f!ht*TS$YE9X~h zq-G{*XD@4riQCHl zqIZAw_adKLqF5}>kt6lWh}1s1K~!&LIGnUyRr{eogUc{@1iAF}4D2hHBCL9w%&EDk f_rIv!@tkQhMK?Jmk^B9&++t*INp5qCTr!MuouTHM>&~G=GQ zqg+BI!?ew%T+?h~N6Y=U)9?M?`~A=7{rG%dpV#w6)p{pgs02PEYJc6*{eC5T#vz6Q zk{d+W8oYYH;>?vg#(arqLDC7mpV!`Pr(LL( z)^7Ppe_NcA?N(HzgFLQvm^&DVL(v$J-C-djhqqfg#ljZFqiGdCM==&6I@jR1iEQOzCA0&a&E>{4PXV5%*FGe&++#({ZIIR)!GyR1|0Kb5B8xcNchf2v zrncFb9n>=Ln98a}olKV>Wi~Z6)r_w;#GD^^d?g?EVRLz+Y-5|dVg-LH4l@B_yP&hY z^|@Byh)-Wl!l%{Pl)3eYSjtQWvveqE{v3=1dDMSru;Ol1)G~Mb_NnOZ)Lm7PV)!+< zUq475$p($j&gR&layPy3*J+H^ zSV$5F+vQ;mlk(aTmu40Nm=*SN(HsQ>!8_=iKiU2-xY z?VlF%vh2~`FawZ<1(L_{`{qH8an$OUD2YT~TSSj6MJLFBzjWhci*&TL!Tv{KYOK$) z?x?>p+*IvnGOKU`lO10>zP-z9b{m!$4=26W-Q%wpVYvHqqq@^+em23gmlVWG+D(L4 zjV~<)w;#J;$xvZTy3!lu$(N@C`b&Qw+`w2MLt39Ol%oixMNU0)E=|_($SUYFq~5aP z?dGKJWBiBm>v5)xy2GPY+66YF+fjoAPuA)x%&@}D!omlhr^iXCT#3M(;ug@IL zPEW6GZ4lDsKkWoB4JCz~v!~<`9hd?t@aZUWwR$wm5iXPYyz{(u(faPtJ}SBLou1Fn zZa|GZ<;lBZArTRgyY&J}>^)pHwICw*u!E z;@|(zSgd5vkwDs&HMvFb>|URu6WfNpjHo(iVe2f4H zqhAD&xrDcqlODLGsazr&yfd#kN1Qs-6yw=>M=hJk1w@J>VaT54Ov|B-LbqX{CXdH! zvG5~!4vBR1+M11xtlAEu-G>T@+}%S}=x_ov$;LH2bA;O&MXpGh>{6ghs@T$PVF7M$ z_M(g8!Evx=qmB7FF!)~fNJfyAqS6UtwGT4MF)i(PwGIsmKikuF%~ z2G+0aTxNw2@+$;RzX&7D^&r=)%F!X;ezPQJd1RYaQ(pV_AueT2*N>QK0RPe9HJ+d% zo$&_j7ZpDpD6PE#FBihEdCQHjx@(9HA}(LYp-41tr~6atc@mQy!O5CT(OHspQ)SO+ z8vlW*-+_9qxA&Qi#)!XL-hQ@Lu!tfDRlm5*t>$d-#haa8&lK|rHbN0w>m8DJRkeH8 zD$8@$fRN#Dyj{*yZ}Fu7-8Ks4Ch}0$r%aC)PD%6D$I z9A+gk8t<{elzliBhJtLgr0417b^8POPG|-TJF~Rh=@u#>9ud(Opj(LOO161gHW8bc}mv2 zue4At&jxi7M886TvIR?@|4O!rr)TtjenIzAn5vW zJIZZBTZyCy@KqcVR%lZ@GvVZ`0hG+cx*|ry2-{p|F<2Bsm{q4NEO>-0LXvvV$TGBx z8IQUL&<>iwn6_Vo&xDg^C(t1-Hln|#nQv}?`%WVLU6mYoC?TM+h!ev7&8wF^YFMe*A5-f+Y;|LA41bc_wT50Y`Fg0I0$yLwnx5 zeV!vIQx#@F6)dql<&6BU3fm4fl?C94G#bOp{8?PjTyrN>E$EAxbBo~ zH}GEvr|c-sET_F6KYn~yB$ge96U;`X&SWjQvt#*KOW9V-U&wf5_m?@^nKHx45|JCm zf7VcBgGyg!H#p4?NUZuo;1~=hxeI#Y|M~Tzq77-F3D$rUlJ}_kzyErkDoKWeNl~bi zatUw%1+~Dr|9&)aHa;#6h<5fzSL+V&Wgb^%RQq=>AE9!P@lHD?UV*)}*^NA`B6!a# z8g{*Lu3gwo3$13dWv81QRYl9~8YUr^E||=w#Kh=hMn!xIxooCUVxg}ngr!OQkGxNQ z{O~l*c~OZeAM(TBIdAQH-u^0t)$HH1tf6o`JtDBscj>l7QdM~mSfz#LTsuyUQDYM{ z!OS+Q$TLGYpyDZse<43sSMdS+L6!(bj9C8x6X^1*kjTDu!^5Zhh3o2X>fiP%B{(@y{D&qe>I#0PWvcq z{w*8oYysk6!D(3Z+@aBiTQ`%YpW~+QTeT8LnOSV$2oKTJjfc&_M@l4@%O4SM^4+j2 zTl~<|E#1LaR$1!YMf~#`0LcM!^#M>MK4}3?b(^JgJG-uV435}B`lBLjQHI+86 zU?8e$cP!N1j-tp)M07cQQ1rb(WAxwoB3Y`Q^ICTTLT0jCTU%W)=Ju};(n6NscX@Zc zuEl%1)#!!1y4C0jc)|p;AD%*{u8W(Rn!3A2oP<3}qRv)VZ+_#wJD}z1bwQh_SVNr6 z&CTe`Pu`lm(T$zDfpZp&hL3FlSGesfiB&7@XxJ*@Q6=!~;H!y`W42G771%8(-RbEa zt0~CH2Y%E$*iYtlt{L1EKi*;12wuB5BetxVA85Sj>Bq6h3#7G&Iw1?=!8n-sOrJjk zEi9NJ<0U%0^Vqo-jK7j!qM(UuBvq~8sHmE{nW)ehi^k<-4PN18*t+MCz@GaguOn$x zg_BrC2{0TL3Pvi|cB)8;l|GS+E8vUQ@x1#?Q=KN|ITr2V$6y*Ewu4tX-GiFP+&p{c z5~kjDs0(HE&deauJG@UVxvxAMCL0o3P!|BKJn84kY!Kni&c?#vpBR_DpQfWF4uqV! zA6Ij2swZ;F#UgTH|L#NV`A%qW6{BvnQVM$077(Z;nDJ*WYet_+#5P&Mq<<0RPRP>$i-^o))xioWz6jhz%X4?TS-VQ~05y7q<#*s+DNp+|f1+VR%1!;(lE= zlHibG0Aklf&4ck{VajK65jxnLyf_mfE$b%Z3?si;2@OPS^Z=tRVt7aqo(OTI|$kqb3bl&(@BN%^44auivj%7a0~6 fDM)MdJ{ARkVuheC;>wiqzlrKA`nnIL_m56K>}jvB_u>Z zsY;P9CG=hdq=a7Hz3*qfnQz{I-{+dy-I<-)bNAZwypH2{UJ=@w>esH^ymIc`xoaAa zRgl1S_n(920&uo>S<60mjx|+7MOn`~V=dFrnMIu~6j$rZ%3;Y-Ne#8+BSg9-uJLp5 zV?Y*|+`F+%{=7_){EQcwRaoPA@3aR>+>O0(H|blYx5atWbH4bJjhW7j(y{UW_2n+< zzlv=6`31Pzl{M)C&1d>-FyMv%%@Ldzz{<)RxR{!ns*x|%eN|f?0TEDCQsOCO0W)wx zSk%!oa~?JK{OGJ))g%U~2li81-5e1$TwfnBNHX zkAB?SE}#4redL*ktR3vKk{WT4&FCZ-~7%__N;mi&A4hJ+-LSTXLpJxK5TAo z&e_Z7*`MuA1|&!z;#s5-@g((<^n^Z%_ah@C@q$`6Lwh@#59aa^W4QsNYo3H-k(wph zM>IJJKF9l;!@~xg_vnI0-yeO{nh@46q2bF*;PLx=-qys>Ff}FR@n<|0&J&AdG_6Oy zlnk{tybUJVKR3k=%y%S%(aU|<{sOISS4A^tm|}K}sbhdhi8A51E=?%-WwlX~wQ7-B zwbAuD)y3wqv5X}=f4HIVRuhAgcIrmMnSZnWk@QRZ${(TTRYsz+DtlPfy*&*^Spu5ZXpq!$6EaOIRXzh#EX#GWQ;lySLKY#X3b!Ng)=c`7amN@oasShU#RUK3eaZQm-h!SYr3a*q z6v893ei`H9-$|)lQ4EV2?ei5`vF*1H9>bd?Dn=YW_5KB`?2WA(+)*hG2C<2m>@R(a zX`H9nk_N;Q;Tf@_!?6PM?nINyk)5w9xWRTSshz|aS7~8kVLz=9&|IZR#}%$(3dU=Q z_3xGl2&Y>*v`RS~9ZgHv`a9iZob+&a4P>O%vr#|dkd_a|?i3AorOR=(azRidtVmsB zhg0*E{AGd+u0WoJULpm^^Y&u**zF70~v5Dayt--M~G;_x|Wkwpq-K*kX zd4oR>+83jqzz=3)l(Lip6irUX^%;A8MtOrJH{Uiqzz)pJ%+&er<>%*f_Qh}S?wHnH zSqwL!QO=c?>0#$*%gP_M8asVge`MRVNX8>(`LP39N+&E|M$y82$P$ImVscRRb~>83EK*D6gtt!HbLT2rM9`ZN!P zTUK9KSnx9;XgP)j-nG{kx-N?n3$EU(^6l<;PVyAZ?UGVBU8e;4~Pd${iL?G`*`8sd-4PLozCko9(ueN`0!fA01-xdJd>E4DRz(^TuarlZxDvCr^C$ z*5_l^Jkx89&|AhAQ|L4rLl_vR2b^Tnk;X3kKHUVt?<*@`Mvre`+P-Ue?^zwq@XEan zRiyZgyWV72LEE%DPkaTNpt!GUWh*8YiVVlZzO~oQSSwAubp*`%pOt%mo)LkKa|8tk%{%rI{SV9WI#H{&`tkmFh^^GiQ zip&yku5az%Z~n9M;&dxqk=j&DK23_l`5YdXsmG#{tWb93a-R&LRJyxLMQ&UqLyP%dl z5cPC_P^fikRrOEG>+35NNS#q@jKfP#jUcZ!Kk4GXuUTgPE`CrvTWGB44DGWS)De*(zw^;HGN|_W@hH);hOMq%xalV+>E8ACB^4C zc+f%iRdt#rI8l%@qo*;^L3Xvi{Z(mQ>oSo@MBxUVYL>;BiLO-YF4aI85KR^?XS7&s zo}L`R(z{+HAP-3?6d#4;4h=`}!G}J{&81IQknaT5RQAlT5#xJ%dlh_l!`E*n+Ef|} zC5m*6HUu;Tlq2D+b-gd=+#c`{&ECvkclrS+y3=Y#_;9DeV?#$Q%L8Y;{vg^`-43^X zAY3RK@oACDWO{GHcVlG`r(>UWD!}8ad9(HwtVsg+S6c_ru%HmwJN++!^v7jQnp%Rn zEY<6;b=kxpPMRkFTHHpq1K%+?_{?;`bG9{VYT^Wtrs&U)Ak=#4to@KnqCR51&r&9n zylAqzY%Dit$kjmz2h*{4331RWUp?hafx3H-U8sK|-LhBjhoLMFMam7#%xXk1vU|hT zuaXxZ=$D#$82b%}hVmh*?UoZ~*O+$&ioV*8|JabZc`e#K7{oUbHaxMG3Rxq1tfKb$ zYZrbnOkfj2V0_u#m;G28%F3pe1))(B5_G1La(ND=qdP@iqWA0g*Cc$nSxLpvAI(=A z(!WM|V`ltu-OOR2e7nAddqk6xQna^TbR$mrxA2^Z^6i=Ap zAQRy&GKee%PJ>wiNUit!BnYJ7J*C3rER4|7>L*V@;DtuTHfaJ~)C?CN<;W_pn>g%> z4)qpd4Q0X=VHj^qVS z0Xhg3hr#(&zKy3?yel$kfOI?duDh@h1Z%1tR6?IPHY#v3jS?m+M96qbjo78I=!pK> zkcaGfoWW;lmUea#0kx%p^Co%jmvF5W0qc!^%_5%*q1 zkF$qGFvrKoleLMqENzv@HeT-@SaOb>tA(ANM$E5q6g_iHa!ZS9H$jyyE3shD!Z`QO zI7#0Ov66zqxX!vi{4?1Q;NxTD6xzV`xuvE%ob58XV`e7zR0saxHG}Xu8kJ&{drb6y z6IA~TvFNDWNW$l`)VUS??qXSBH$o*PC1aPWAp-SfWguvGXXjI?#5bniB?G2LM!D1@ zRiMU-B2N-RJsfb1IiVN5N?kV6-dP6+Z`v5a^Er>gEO2^f8S-AyOt#%wOmy z+f(1E$oLQt9LyJ-5XVH#lLy#?F<6y{($AFZKKL&qtHKXo8U6-b+VZo$lHXbL!sk)x8Zmc`@n% zN){0hQw%fQZUZ!06hRzvt$R4r`$;`RQ_N)9ZYI>cXT#$O;UwR#^_gg2rr#G(?zmIs z$pa?95u@C402N4M;_=_-yEEp+?|DgO4Ow`*_W6|)n*?OO9RT>R9{!)cexg zW^#C###Kjd$6boshF`l8EuWM8^N*R0wcW&SlAtAqN6HD`7B4AI^k# z1c273t{#sUz(W1@|0dn}fR=|yd3E+U$Rhd^ke4JwKz@hI1uU`dZ_@<|3yW2Wmy-&0a*z~k}mi@!|YfN%DSBbd#j zP2)B`%inDP#F$`Dn#Os{3YzxmlaPf=lA!_j_V1r8EPLwV29?f^rK`?7$K+3@0HlLG zX3UTf;O@DQfW%-hd+X!rX=!ecwSJNNec!OIyNDj`Z4@+zTxAQ?CpUblD-xvb(*q(7 z^anD=>!m%TbcuamTR^xM)wd%mCVx9U$J4!$>?=$v1Cj1$Q^M}(dBsV^{I5s zGdLxZ=)npI8ynlJYNO`zd@6q?M$6RRh+M#bZ8$utG{J)%>k1zGfRPb+KGy$2l+LaT zK$jYcOB-8TZj%=rTh10yXc{C)c&1F}fAOnDv)#M;F>`UngE{7Y8R^vb)`mVJr$7U^ z6yh5fJ<5_n!$;S2mlYENQ_e;)Aor6zcFXNf$h@NP=+E1~-%l|81_)mV692QPw6qd! zTRDP0+NI^A=e+;W_P6~#p0i@5g{dH5rd}fVx@{_P|Bk~uFHyE09v*&v&?H>*6HxWY z$AW@_Wopg+QmPIssbUmH&^`pzaI%|D!x>Ldstu5KcXto|komDsoqE{GF|EB!UeJOB`~PrmCS`bKag|%Tyd>sf7tT{HnOSD)O^8E`@w>PcFgZS=5hz!t>79Ln#13ZyjtKiaJ} zih^mlEw)oWD)MP&LRykPAn7v^VY=zr(53S=OOhKCuS#4xf*SXB!Pu80us&Ub&2)n1 z2abnYi4RWQGkyyXQC5d63(fnSpJ6pX)gAK{v#!BzEzm<}Q|ihEkif4+?J#KwWHbOn z6+5wQAC~Y(FR~@~^w~EtabnNf$j9VSAlv(qop#ZlbWKwYKCrg!kCxsaw+~lKbxZ!! z9B|2d-WF8tG#!(mb7K-Ix1_s`l|l5^@%nbWe7Yyxc#nsZ@$man`TdqcNmG7W7_Lju zn;U?nVqGw0m+XAD)gk^xemSoS`}I)$EQr4E57biHm3yp5-D^)Un3wb)D6X;Pt5Yes zXHb6G5zsrs=g$*I<7i7Be!~`%RbN*>z}yl?NI@Funk2>~BN_~GuZL9jdua9We+=;| z`d>^-Ysijioi?lH4NEDs(`_L7Vq$;i;&xT|16tvYL254r%iD-*uK2MkwXsT0xU<7Q zLtF6Ypzn5<+yj$d=JQISuPjZPSurHz#;#AY8)L+HMOlE7iWlN_)WOx|0SBU zk2&Q%{|uK~x;~qTGQZJlW(bd-vM1w<8_HXyXQIEwFlT6AG?f}6ZE`c2Y`n02rEF1%3fQ*6ilj*IG!cPr64uf!}fnLYoJ z`YPZErxlL=VR8Pe^fl_1nZQ<(pJHiPP?qpDkKO}g)=cqfyFed1nZ4WC;}u-#ae7(Q z##}+6Y$?l-)~+7e9K#8ZP3-?Jn*5%`eB8U@cDS=#pT-kA{FOijzVqfK7;AZlxyyLD zuE4UY&GLS|Tmd~*bI?or>X56|!R0ss5W@^x`2%jnUaLzkX4 zCt~Ro7qR|efrIv~*EaoaGAeq~x1|+E1tT?L+LJ6@tPO26xSZu&+wemm3=c z6DkZuAOaw&OQPhZAMyh0221L(x_^n&;kqyCRK!^^#}koaACO=4gf5zh$-ME5NAAs5j`rQo&d&Qn0<|d9LOqKH ziz{?Rqre?Hh1mfHA*=(i$wU^f+llkNAW8G{*>D)J4Kt9hPl4sV`oVK?JoCvK<4wfj ziusAQ#FRZV3(2=N67SfsKZ8O4)a1}}cv93CyPO?xN^2G@8=v^o%8h6umi)xxmWEHN ziGCy(M+`PJFh@Jx)%bxI_|dcpM8F?tx&Nv_s!s_#0mXXoGygp=KFDR)*7SjF8*VIwUl#`kJsJwYeK(|^iQF&j5m>vTS zp|bR2;@a80%jzPB%5Dz%cJvkMd;nlyc36szP7oZuEc%wc$kFu>Hpvj1Qxd;0b!qZ< z7@HB#3TrlV5Hp0nJc2okRNOa9yLFd}f2T66Td@X41D<~iCEV~~W*f!Z_zeA=q)$+P z)7rx(QL6xX3f0`{I-;S!cCiS0opaR#(LY!G3!iM;+#HgexJnnTdmb!J5XbLe6e9&= pX0qBYPO+<)rO^N1q1U^9pT~lZG%26hb@ER%4OLB*64;Bt{{fn?HMsx) diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-rgb.png index 8803719c8afd5c49c232aa0b34d730e8a65744ce..37c6a747c8c48e6816f8ca85adf4899500185539 100644 GIT binary patch literal 20621 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 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-rgba.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-rgba.png old mode 100755 new mode 100644 index 7b7a92e5832bb6addcb20f01999f6ae0f216d756..82ea7f71ced042237363411c0a897530a6eccd82 GIT binary patch literal 22843 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 j?tjT#-=z(WjZ>Jc~S literal 22229 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 delta 1818 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 b)X4?qeU~9&UC|fdH5PF%@r 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 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-simple_shading_diffuse_mdl.png old mode 100755 new mode 100644 index d4f6aa7b4ce0d709368937323f6dd42ad13075e1..79819618b045ab222f50090701a59775afe8525d GIT binary patch literal 6893 zcmb_hg;&#E-2V{*ieLcJp`c9Z5GJWU44e`o4gm#x=!PLJp(qXG5$RM=kkQ>;(jqas zCQf35(an4D{SV&r-g6k7!|&dmd++!Asb9!rO*Mvd9OobiVn9Anc>;cKpZ?HMf=?SK zVj2W-lpHHFJN8&LO3PeRFz;_JTT?(@+5i}&fS{{MZPVf}S{r1Fg^ zEu5>jgq)n$;Vya7EdQ}~>8qQ%t;U`UF`YYjPbD{~g90+`In19~J(+!X!Q$%cfROZ* zM@83;$88I~dcMJ8krhIr%^QbfC6)~*#|JBBc}`%7PJh3U^!VQt$Q`qAG?%P`!r|ti zR#-Y)h)S)IP-?2GJ*zhq1*mLs`DdVti4+^!l?ZM{FPH9E5p#3%kn4d2&%-x!PP_fe zCoVNp3jH%aJ5z-Ivk}V8SrH`1k)d$t8Rj^*)( zzL1}-8wyugkx?qz-bY(Fcj91zT#=L$SA7P>Xd}|6~bpCvFSHcj?2>saA z+6_&9?V722i{3;;*cEUW8VOYQE}H4fq&1rq&C=hysG?muW*5dPR_`>|{FaKg_+U=3 ziA@I;BXNa}x;Pb%knhV?mDdf^8tX|A;<fKDX2~8;Q45|%gVIpEynBB4Lh+a#G`r;mF5RJqD5D=6 z6cnUPm69e_WpZ#&3?1C=+iaTugPW#wLDXq=q-*9Bq z7wg0hyf$+io?Kd9-j^iX|3YYGrh(Tu9?#Z6*wD|@)n;Z|ZivR_%uQD9Mxan@oq~nl zp7W%pmye?aR(uiHF6qY>iAI}`%{dJX57!-SPb{W--C`!bP(28|6CAGdZ@6KlQ&Vl* zf^%oFUBv{N8iI0lbJAh9Be{ztl4jPbwA zGVm=PE_#s;p39&5-d}# zZEeGHXj}|CF41J1krsuy8g{s&C9e%vMM(I1#-Vm2D z@GaB&!-Lk<9lTk=-iw>>j4EN{i($j_Asd#9Ek|M;Q#Nmkii!Ys2tCVBmUFe4s&-ZA zr=@(GpP$bnq`SGbl>$dMdshk|!zD6&r28=XKV20shiq}`A`Z&0k+b@+sb}r1zvj<1< ztib4K7avK!EQN{94U7+2?29rBOpkD8BI?PPxvx%l?nvz@hcDmRz^!$QH3%Ek#%l;4 z47vI^Ijs$4>tL}4!m)jQnv+D(bs{%!qUBPm^CK~shf&NCs$rp_g}u#8|Hk~@IUwI? zbAof73-H~Y3+mu`eLgF@=_%1V5tLYYp0V;rm^Xvd@!H(eoEv--LioR=CcIpoFQ_YG zu|y3uCYePD6iQ*b!;1C>U5{29PL$B|@3B~Lq0-kFAq@?Ui3-P65{Z%KolKHO)E9h5 zq$$FG(JC=;irWsx%@{O66crUUD!0?Owaq~rnIio5D+;tk+0L63d*t2hc19vYgu4+4 z1fbaw7C|Hu{rS8Ph!sdN z>YbG_DyU!SU?us^t5lEqvvw3k)y-xXcog&u4T&ytcQ79X&O&DTM)lrzXvUK#Hq(%j zOb6SLEyyC%>Sez_`KpsBULkUR?aKwssR6CkKbu$S?tIqSNR`+yDb&`#$Q|^%7+C^Ctxt3t6#w5r3v55(JB_#~&C%u}= z7x`E$7C@wTV91OL0Q%73d3NPm$_b6+EpB%9n_^w1u%o@+iu2 zW+kCc*LRggCrie3;Kz#z90XOJRJMI`+mnN+Q2$tBWxB4WvXbqFE=ef#GGmbY$|$;I0~Nn6Iv()2 zmEtAPm5<^?%}=R*c^?QBSZG!4y*1_Q4e2Q}thWMx|Nafi=j;vA*EV^#3i}DSzjKAg z&pc_R_*7L@m61V(b9CYo44ntc*}X&xRk#it5723)nEmnjAE0T&)D`@C^n}zb{;5}~ z`2^UG>yGoUg*fB(4)HI0Cz~I_wM)rxfsj3-{ldjyJ?KrbjC@0$KR0#wJE zk&)36$=k2-`y9((yKxxv%qYQIIh9~a>%;|!yJvO(==g%t?#nC1&*J~rvJ}*98zjw6tg`AC);}e86YPvaM?wPiHT$~dZWGd9lL3D{&V3g&|{RVnOWAh27W_UENqc6OYu63!vLh4mY$aCVDpxr`%_Jjt}=9!vwmW zffVJV!~Y$;KAYOQ#foSD>g6xNRKO4d>C`ojz`5SLC|)_|1Sd@;P}k+>YhI+Tnc|_N z?n}OdCwtr&+lyNGDZlLASno54(X|&jKDyo5e{zsEvl^*#?Op#o+V9vKgU{6!GR+0!+c&xk$d$A9Gxc?)rBAh)yc8Qk=Yr970ukE_ExtXlwGd@ZP zU^RNMF{o)GlTWDwq_*UEXHAkgO2ahuNxLGMKnL+es-aM+gxeZaZ-7&)ej%FFO9L4d zROIKA$|oE?ntOV9?3VN^ZFto=){+BT<{Ga~>Jsg#~c zVcLlPZ+i3vBdFods7mWD3u#7Q3Lt@&S64ekfG?4UfEQ+9WHiXr%^LJv|Ak!10xlg` z#Msy?{5C#5K3~6n1t>U4`|$md!)aYRR2%?`l$s9ZyjoBCd0nq=KTyO-Ojz$fmzlcP zJ6NnunL&YJ4QQd3IXY@ew;CW6&oeUisZxc)#!I+~%dEFNQsU6}cP3v&UfKkGJOezB z@Rjy>0c2vPrveW`TY*Q*)^@qpbEBuM`S|F79B>DN_Et(8wqZd?6dgaxuI2z3IHQL; zljcp?9VbZQIZtyP*-UySaQTBd`|^GLSZ+o&L&GUxPE$zPS@#F}%u5~P-+hO(w2ubR zv-Ug(7E-v|t%h3-f*NhgZiN;DV-DQ0%l@&2vCr1o4~|^-cl5fL6Gn99f?adQ;bX*&!CIv0#sF1?|=TUpTNn* z)noGYRK;3Ey$I7{m9g|YBnNqBcoz9NIs(hdDeKT`G~j=RzA)~ii$)vB3cy*J$SZY5 zKacuUQBDbMZngrE7r3`EH=y?KOFmLcoRQZ})P4nX=(dab=U^(3I}zPDr@SG(YM1Ow zx6hO3T7qQl$Eg=ZatEa&@bE*qEIZMUFC=*s=perCkQzbBzusQ?AaFB3DRL}WDW=+f@g*)M|B=`MrRpf}UNxTxG z0;GxNc4B{w_)_l@R-jWVHqJTct)=uj6htMxI?9S7NwWTlb zm)YXvi@~JfUv|1!<(Kz-j}KDf(VaotSCO)Szr?`cnXo2~)Y&^wOyBbX=$}m z8~fnk!~}lQ!==^`|OAYbz!F zAXXuS2&Oh(xtJrQzCGLYZg_ZDGwUNBj*Not#x5*$$C-ZWtA@n9fT z#VvzdXdd0r2Wo(j`r4uEU9VbavA5ZJs;hr_xlH^n{`mGZvHJRMj60C=*lm5(F9ytj8RUEa!_&1j@Q|gk~wj00cAwK*Zu-5xq z%Q%pR)q%!kVPOd``uzDb=%|LGq5+#S6N|NN5ez<7^MM-o=n8O2 zxPIHOU`lRHwgq*^!-z8%(EOQF;c%#S?{^YNJ%LX0pa?As>s|o9$m@{YVHv-xp)%0- z6?87SMW}G9Re$EghY#!P>!ux;d!b*G*lq3XT&iBE64PN_F+$J4hMwFGsDOhFYpl+7 zue4j=F%6`AuGKyYGIz_xPA}C-a`>FnC}G2})`R?KAfp6bt&cce-<_%BrcQ!4cR-s7 zJ16JCTFZr!gaT@Cxga}+-eefaBt_sWL4wh6yf3uM^9*@9M;d5+iy`)ym>6^gO(J-9 zs1%e8c)3>ZrcxBSCLkH{f116D(;J5uc&92^dA_2+bsRS#k-)#Ric>$XN3 z8zD{LQuUrZF{zk{$a46&(luj6Km$*fnVH!gCl+-7Di`P#J4HoBu$d}-+Y{rDSp$7| zdX^T_R83e|*uN$V_35xVClBxrK~~;4;O1(&AsvDX0=lx=W%0==)q?~iA_Bb6=;ow8 z(ODN7-}CuwV&&j)T0Sr5jSOl{-GQ0>JGUX-kPplSTa=fRb2_5wK&Qi|fpV6W*Eu5; znQjON|77Hrb0JMueQ(|92rqLsllk?ilpYIq`+;B0$al}&`}{wNCtAFnY>1@)>zkh_ b)X4?qeU~9&UC|fdH5PF%@r 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 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-isaacsim_rtx_renderer-simple_shading_full_mdl.png old mode 100755 new mode 100644 index d4f6aa7b4ce0d709368937323f6dd42ad13075e1..79819618b045ab222f50090701a59775afe8525d GIT binary patch literal 6893 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 b)X4?qeU~9&UC|fdH5PF%@r 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 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-newton_renderer-depth.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-newton_renderer-depth.png index 99b3685ba904f5826d74d3554a882e91700369d4..8abb9f167e5748e2c74705dae74ac4a27c267871 100644 GIT binary patch literal 4075 zcmeIz`#%%<9|v%qD9L5j$z^=WrQEVgLT-y(7o(Nj#W@WVbBVc6X)~9?kxM2i_nJ+e z*~rKVU2JY`G31i@W-)W0TgKP<6TXl0c$}ZSACJ%H^V8?^%jf-mJ=0yFc2W{R2@w$y zDF=IN*zURd_Y(hQcMlHdmx_qU{OMqAc|G! zt<$8IeMg~Ahn-CLD)6(t5UynV7 z*>biFB-;h_faDh6-oV!=nz@oRS>oBw7L0D-gast#EhH{43V7`Id8UfYDhZaaY~}cT zalZpVH1mB56=9TKGZd2QKNw8GcLxyqGs=Rv@$j?q;jWsPAOj$IC}ep_#Ue3${wYCZ zz!AN>8RK4Azv^2H$KR)c=|4hL`sC5m6u$p ziuP}8pmU6+Q~r8VE;kuvf!=ydi40s0?H(EF$o@E4c;@EjY#O+tgp#VyL>t4;@$$WU zPIiyFK%!}*&>AEvvuFBV8q1N6LeV8^LrGuBW zqZ1Oo&aXM|78F|RR$_ip6@#P@U9~HxmNyJ(1=pyxFW-k4VF6e-9ZWsJ>E{HX`ax|2 zWe{hkNhRaDK!W z&Z!+LQv$2WF`k-+R$sP`iHX_mp_6Lbm2upmW;##ZiN)jH7Ot?Woww(^^??t&GCi97 z;0Dj^Y4rNb=HEj=Yi+Y5zxhO&jBWy_5g8TEbZ` z6$ZQRCeh2ojFjd)Hfc;9bN_bXbhWyCenx!>-Mk})bV!Qrn-;-8Bhw%!_aDNwonEEJ-Jkm zu!W(Pp^yTNL)lXkN^b-R!(!`Or;w2YTAO?rB=v z8MS7LYwuKh6X=SyCBR<$k;cR)C=^=7dI1YI0K!XL>e!&GzfL#Ny}Mfdp1$0In(huj zsGZ(it!E_IUFltjA&Xs{t5MX;wMw%sUtF1Hk;&(MZv0CaDEQUz)>lldP%N~HlNqK?MCQlJAN9xQ+XXAy2)#bpGvggUeh|CHS){+c(a76 zgyWMO)EF?s>E~xvlCC7T&@xiY@>%QIE6NFsNzPB?`xB3n@-8f6}VJaH$)qauj zZD_H3Mr&RMVJfGq9cpKXUDJ?kuZ1eYXZc7ht+DajNHiBF{&_m8X_Q3X@I5VCSXfxh zzNjQ!0yoXMr?s1q3?B{*@AgNOs7LlnW8jBs)9!rp@VKfQ8ApuVlmoJS*ppu2nkgLGsWL}&e|J6z#Y2?r%&^&3DD10WgR(Ub%n z@djZ~CHKqm2XFL{{)a~(@ElZr0dD=4{$j{EawJzKDGq#awyUKoREtSsE&*CwTVn!d zRN}=NZq?P*MgLJyDW@i$f};7XR%K#$gS)w`tWN5CDs)Iaf&M{rd!S2j?l?{c;r0Sr zu9jZI<}GTW(R6;alVV?cj1q!93(zd{tqUB@n0)j+{=2W7T@Fep9r3~3Mp%z}!@fdf zk$9?5Ya=5g9{2KvmRHg1GsP%>@bT*{48|2+0S@+Zz(`x#BZ)u_xny!^$E^LJr`FT;U&{qF*{9&@F9r(Lq zPxO}gtY^*D;g2qvWE4$rAk}n-(5BP^tV}Y)86Tm1<)09jQ1Fm=Gn2b&DTfRcqD#{- z0NhZNvO>i_@% literal 6228 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 diff --git a/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-newton_renderer-rgb.png b/source/isaaclab_tasks/test/golden_images/shadow_hand/physx-newton_renderer-rgb.png index 6a22c884e0b2af0c2b2514c045df321c7744b32e..bacc0fcc47cbe14101b19be4460a4c0a5161cbb5 100644 GIT binary patch literal 8424 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 literal 12951 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 literal 14098 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 W-kQY*fzP_($jc~87fXHo{J#J>JJ`Vh diff --git a/source/isaaclab_tasks/test/test_rendering_correctness.py b/source/isaaclab_tasks/test/test_rendering_correctness.py index 3d3377901916..24ee5af0fac8 100644 --- a/source/isaaclab_tasks/test/test_rendering_correctness.py +++ b/source/isaaclab_tasks/test/test_rendering_correctness.py @@ -52,6 +52,15 @@ # _PIXEL_L2_NORM_DIFFERENCE_THRESHOLD = 10.0 +# The max percentage of pixels allowed to differ. If the percentage exceeds this value, the test will fail. +# The value is set case by case based on the screen space taken up by the env in camera output images. It +# needs to be large enough to tolerate minor rendering noise while small enough to catch unexpected changes. +_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME = { + "cartpole": 1.0, + "shadow_hand": 3.0, + "dexsuite_kuka": 4.0, +} + _OVRTX_DISABLED = pytest.mark.skip( reason="OVRTX is optional and experimental feature and temporarily is excluded from testing." ) @@ -66,9 +75,6 @@ # "img_result_path": str | None, "img_golden_path": str | None} _COMPARISON_SCORES: list[dict] = [] -# Environment seed. -_ENV_SEED = 42 - # --------------------------------------------------------------------------- # Fixtures @@ -643,7 +649,6 @@ def shadow_hand_env(request): env_cfg = _apply_overrides_to_env_cfg(env_cfg, override_args) env_cfg.scene.num_envs = 4 - env_cfg.seed = _ENV_SEED if data_type == "depth": # Disable CNN forward pass as it cannot be meaningfully trained from depth alone and will raise a ValueError. @@ -652,7 +657,6 @@ def shadow_hand_env(request): env = None try: env = ShadowHandVisionEnv(env_cfg) - env.reset(seed=_ENV_SEED) yield physics_backend, renderer, data_type, env finally: if env is not None: @@ -662,13 +666,13 @@ def shadow_hand_env(request): def test_shadow_hand(shadow_hand_env): """Camera output must contain at least one non-zero pixel (Shadow Hand vision env).""" physics_backend, renderer, _, env = shadow_hand_env - + test_name = "shadow_hand" _validate_camera_outputs( - "shadow_hand", + test_name, physics_backend, renderer, env._tiled_camera.data.output, - max_different_pixels_percentage=8.0, + max_different_pixels_percentage=_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[test_name], ) @@ -691,12 +695,10 @@ def cartpole_env(request): env_cfg = _apply_overrides_to_env_cfg(env_cfg, override_args) env_cfg.scene.num_envs = 4 - env_cfg.seed = _ENV_SEED env = None try: env = CartpoleCameraEnv(env_cfg) - env.reset(seed=_ENV_SEED) yield physics_backend, renderer, data_type, env finally: if env is not None: @@ -706,13 +708,13 @@ def cartpole_env(request): def test_cartpole(cartpole_env): """Camera output must contain at least one non-zero pixel (Cartpole camera env).""" physics_backend, renderer, _, env = cartpole_env - + test_name = "cartpole" _validate_camera_outputs( - "cartpole", + test_name, physics_backend, renderer, env._tiled_camera.data.output, - max_different_pixels_percentage=2.0, + max_different_pixels_percentage=_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[test_name], ) @@ -739,12 +741,10 @@ def dexsuite_kuka_allegro_lift_env(request): env_cfg = _apply_overrides_to_env_cfg(env_cfg, override_args) env_cfg.scene.num_envs = 4 - env_cfg.seed = _ENV_SEED env = None try: env = ManagerBasedRLEnv(env_cfg) - env.reset(seed=_ENV_SEED) yield physics_backend, renderer, data_type, env finally: if env is not None: @@ -754,13 +754,13 @@ def dexsuite_kuka_allegro_lift_env(request): def test_dexsuite_kuka_allegro_lift(dexsuite_kuka_allegro_lift_env): """Camera output must contain at least one non-zero pixel (Dexsuite Kuka-Allegro Lift, single camera).""" physics_backend, renderer, _, env = dexsuite_kuka_allegro_lift_env - + test_name = "dexsuite_kuka" _validate_camera_outputs( - "dexsuite_kuka", + test_name, physics_backend, renderer, env.scene.sensors["base_camera"].data.output, - max_different_pixels_percentage=10.0, + max_different_pixels_percentage=_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[test_name], ) @@ -769,25 +769,25 @@ def test_dexsuite_kuka_allegro_lift(dexsuite_kuka_allegro_lift_env): # --------------------------------------------------------------------------- # Task IDs that expose camera/tiled_camera image observations; each is validated for non-blank rendering. +# The max different pixels percentage is set based on the screen space taken up by the env. _RENDER_CORRECTNESS_TASK_IDS = [ - "Isaac-Cartpole-Albedo-Camera-Direct-v0", - "Isaac-Cartpole-Camera-Presets-Direct-v0", - "Isaac-Cartpole-Depth-Camera-Direct-v0", - "Isaac-Cartpole-RGB-Camera-Direct-v0", - "Isaac-Cartpole-SimpleShading-Constant-Camera-Direct-v0", - "Isaac-Cartpole-SimpleShading-Diffuse-Camera-Direct-v0", - "Isaac-Cartpole-SimpleShading-Full-Camera-Direct-v0", - "Isaac-Repose-Cube-Shadow-Vision-Direct-v0", + ("Isaac-Cartpole-Albedo-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-Camera-Presets-Direct-v0", "cartpole"), + ("Isaac-Cartpole-Depth-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-RGB-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-SimpleShading-Constant-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-SimpleShading-Diffuse-Camera-Direct-v0", "cartpole"), + ("Isaac-Cartpole-SimpleShading-Full-Camera-Direct-v0", "cartpole"), + ("Isaac-Repose-Cube-Shadow-Vision-Direct-v0", "shadow_hand"), ] -@pytest.mark.parametrize("task_id", _RENDER_CORRECTNESS_TASK_IDS) -def test_registered_tasks(task_id): +@pytest.mark.parametrize("task_id, env_name", _RENDER_CORRECTNESS_TASK_IDS) +def test_registered_tasks(task_id, env_name): """Camera output must be non-empty for each registered task with camera-based observations.""" env = None try: env_cfg = parse_env_cfg(task_id, num_envs=4) - env_cfg.seed = _ENV_SEED env = gym.make(task_id, cfg=env_cfg) unwrapped: Any = env.unwrapped @@ -795,8 +795,6 @@ def test_registered_tasks(task_id): if sim is not None: sim._app_control_on_stop_handle = None - env.reset(seed=_ENV_SEED) - camera_outputs_nested_dict = _collect_camera_outputs(env) num_camera_outputs = len(camera_outputs_nested_dict) assert num_camera_outputs == 1, f"[{task_id}] Expected 1 camera output, got {num_camera_outputs}." @@ -808,7 +806,7 @@ def test_registered_tasks(task_id): "default_physics", "default_renderer", camera_outputs, - max_different_pixels_percentage=5.0, + max_different_pixels_percentage=_MAX_DIFFERENT_PIXELS_PERCENTAGE_BY_ENV_NAME[env_name], ) finally: if env is not None: From f0f48925662a76ee5547798b3fdacc94850efc4a Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Wed, 22 Apr 2026 22:39:55 +0000 Subject: [PATCH 29/37] prep --- docs/source/features/visualization.rst | 62 +----- .../core-concepts/scene_data_providers.rst | 7 +- source/isaaclab/isaaclab/app/app_launcher.py | 188 +++++++++--------- source/isaaclab/test/app/test_kwarg_launch.py | 4 +- .../isaaclab_tasks/utils/sim_launcher.py | 6 +- 5 files changed, 105 insertions(+), 162 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index a1961ecccc84..423907a2bbf3 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -328,31 +328,6 @@ Rerun Visualizer record_to_rrd="recording.rrd", # Path to save .rrd file (None = no recording) ) -**Remote viewing (SSH / cloud / another machine):** - -Rerun serves two TCP ports by default: the **web UI** (``web_port``, default ``9090``) and the **gRPC** -endpoint (``grpc_port``, default ``9876``). Allow both inbound to the training host (or use a tunnel -that forwards both). - -On startup, the log prints a **RerunVisualizer Configuration** table with ``viewer_url``. For a host -reachable as ```` (DNS name or IP), the same shape as the code uses is: - -.. code-block:: text - - http://:9090/?url=rerun%2Bhttp%3A%2F%2F%3A9876%2Fproxy - -If you override ``web_port`` or ``grpc_port`` in ``RerunVisualizerCfg``, replace ``9090`` and ``9876`` in -both places and, if needed, take the exact ``viewer_url`` line from the log (it is built the same way as -``isaaclab_visualizers.rerun.rerun_visualizer._rerun_web_viewer_url``). - -Do not copy a ``viewer_url`` that still contains ``127.0.0.1`` or ``localhost`` and open it from a -**different** machine—the embedded ``rerun+http://â€Ķ/proxy`` address must use the training host’s -address that your browser can reach. - -Rerun startup uses the Python SDK through ``newton.viewer.ViewerRerun`` (no external ``rerun`` CLI process -management). If ``grpc_port`` is already active, Isaac Lab reuses that server. If ``web_port`` is occupied while -starting a new server, initialization fails with a clear port-conflict error. - Viser Visualizer ~~~~~~~~~~~~~~~~ @@ -368,41 +343,6 @@ server, allowing you to view and interact with the scene from any browser. - Recording to ``.viser`` format for replay - Environment filtering to control which environments are rendered -**Remote viewing (SSH / cloud / another machine):** - -The Viser HTTP server listens on ``port`` (default ``8080``; set ``ViserVisualizerCfg.port`` if you -change it). Allow that port inbound, then open: - -.. code-block:: text - - http://:8080 - -Use the machine’s hostname or IP for ````. On startup, the log prints **ViserVisualizer -Configuration** with ``viewer_url`` for the configured port (defaults to ``http://localhost:``—replace -``localhost`` with your remote host when connecting from another device). - -You can also enable ``share=True`` in ``ViserVisualizerCfg`` to request a public share URL from Viser when supported. - -**Launch with Viser:** - -.. code-block:: bash - - ./isaaclab.sh -p source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env.py --viz viser - -**Configuration example:** - -.. code-block:: python - - from isaaclab_visualizers.viser import ViserVisualizerCfg - - visualizer_cfg = ViserVisualizerCfg( - port=8080, - open_browser=True, - label="Isaac Lab Simulation", - share=False, - max_worlds=64, - ) - **Configuration options:** - ``port`` (int, default ``8080``): Port of the local Viser web server. @@ -421,7 +361,7 @@ You can also enable ``share=True`` in ``ViserVisualizerCfg`` to request a public Performance Note ---------------- -When visualizing large-scale environments, consider: +To reduce overhead when visualizing large-scale environments, consider: - Using Newton instead of Omniverse or Rerun - Reducing window sizes diff --git a/docs/source/overview/core-concepts/scene_data_providers.rst b/docs/source/overview/core-concepts/scene_data_providers.rst index 8b6443e258c0..f1a5e349b775 100644 --- a/docs/source/overview/core-concepts/scene_data_providers.rst +++ b/docs/source/overview/core-concepts/scene_data_providers.rst @@ -48,10 +48,9 @@ The system has three layers: PhysX Scene Data Provider ------------------------- -When PhysX is the active physics backend, the provider **loads the Newton model and state from -the interactive scene’s cloner prebuilt artifact** (see :class:`~isaaclab.physics.scene_data_requirements.VisualizerPrebuiltArtifacts`), -then syncs PhysX transforms into that state each frame. Newton-based visualizers (Newton, Rerun, -Viser) require this model/state to render; there is no separate USD traversal build in the provider. +When PhysX is the active physics backend, the provider **builds and maintains a Newton model +from the USD stage**, then syncs PhysX transforms into it each frame. This is necessary because +Newton-based visualizers (Newton, Rerun, Viser) require a Newton model/state to render. The sync pipeline: diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 308e081c8226..c9e18b54f54d 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -33,43 +33,6 @@ # import logger logger = logging.getLogger(__name__) - -def sync_visualizer_cli_settings_to_carb(launcher_args: dict) -> None: - """Write visualizer CLI selection and ``--max_visible_envs`` to carb settings. - - Callers may set ``visualizer_explicit`` / ``visualizer_disable_all`` when those values - were resolved elsewhere (e.g. :class:`AppLauncher` strips flags from *launcher_args*). - Otherwise ``disable_all`` is inferred from ``"none"`` in ``visualizer``. - - Also used when Kit is skipped (see :mod:`isaaclab_tasks.utils.sim_launcher`). - """ - visualizers = launcher_args.get("visualizer") - - if "max_visible_envs" in launcher_args: - v = launcher_args["max_visible_envs"] - if v is not None and int(v) < 0: - raise ValueError(f"Invalid value for --max_visible_envs: {v}. Expected non-negative int.") - - cli_explicit = bool(launcher_args.get("visualizer_explicit", False)) - if "visualizer_disable_all" in launcher_args: - cli_disable_all = bool(launcher_args["visualizer_disable_all"]) - else: - cli_disable_all = bool(cli_explicit) and visualizers is not None and "none" in visualizers - - with contextlib.suppress(Exception): - visualizer_str = " ".join(visualizers) if visualizers else "" - settings = get_settings_manager() - settings.set_string("/isaaclab/visualizer/types", visualizer_str) - settings.set_bool("/isaaclab/visualizer/explicit", cli_explicit) - settings.set_bool("/isaaclab/visualizer/disable_all", cli_disable_all) - - # Sentinel: ``-1`` means ``--max_visible_envs`` was not passed (see ``SimulationContext``). - if "max_visible_envs" in launcher_args: - settings.set_int("/isaaclab/visualizer/max_visible_envs", int(launcher_args["max_visible_envs"])) - else: - settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) - - # Suppress noisy debug-level logs from third-party libraries logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("matplotlib").setLevel(logging.WARNING) @@ -86,57 +49,6 @@ def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, f"{self.dest}_explicit", True) -def _parse_visualizer_csv(value: str) -> list[str]: - """Parse visualizer list from a single comma-delimited CLI token.""" - valid = {"kit", "newton", "rerun", "viser", "none"} - token = (value or "").strip() - if not token: - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: empty string. Use a comma-separated list, e.g. --viz kit,newton." - ) - if " " in token: - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: spaces are not allowed. " - "Use a comma-separated list without spaces, e.g. --viz kit,newton,rerun,viser." - ) - - names = [item.strip().lower() for item in token.split(",")] - if any(not name for name in names): - raise argparse.ArgumentTypeError( - "Invalid --visualizer value: empty visualizer entry detected. " - "Use a comma-separated list without empty items." - ) - invalid = [name for name in names if name not in valid] - if invalid: - raise argparse.ArgumentTypeError( - f"Invalid --visualizer value(s): {', '.join(invalid)}. Valid options: {', '.join(sorted(valid))}." - ) - # De-duplicate while preserving order. - return list(dict.fromkeys(names)) - - -def _normalize_visualizer_intent(intent: Any) -> tuple[bool, bool]: - """Normalize and validate upstream config visualizer intent payload. - - The expected schema is: - ``{"has_any_visualizers": bool, "has_kit_visualizer": bool}``. - """ - if intent is None: - return False, False - if not isinstance(intent, dict): - raise ValueError("Invalid value for `visualizer_intent`: expected dict or None.") - - has_any = intent.get("has_any_visualizers", False) - has_kit = intent.get("has_kit_visualizer", False) - if not isinstance(has_any, bool) or not isinstance(has_kit, bool): - raise ValueError( - "Invalid `visualizer_intent` values: expected booleans for `has_any_visualizers` and `has_kit_visualizer`." - ) - if has_kit and not has_any: - raise ValueError("Invalid `visualizer_intent`: `has_kit_visualizer=True` requires `has_any_visualizers=True`.") - return has_any, has_kit - - class ExplicitTrueAction(argparse.Action): """Custom action to track explicit use of boolean flags.""" @@ -170,6 +82,96 @@ class AppLauncher: """ + @staticmethod + def sync_visualizer_cli_settings_to_carb(launcher_args: dict) -> None: + """Write visualizer CLI selection and ``--max_visible_envs`` to carb settings. + + Callers may set ``visualizer_explicit`` / ``visualizer_disable_all`` when those values + were resolved elsewhere (e.g. :class:`AppLauncher` strips flags from *launcher_args*). + Otherwise ``disable_all`` is inferred from ``"none"`` in ``visualizer``. + + Also used when Kit is skipped (see :mod:`isaaclab_tasks.utils.sim_launcher`). + """ + visualizers = launcher_args.get("visualizer") + + if "max_visible_envs" in launcher_args: + v = launcher_args["max_visible_envs"] + if v is not None and int(v) < 0: + raise ValueError(f"Invalid value for --max_visible_envs: {v}. Expected non-negative int.") + + cli_explicit = bool(launcher_args.get("visualizer_explicit", False)) + if "visualizer_disable_all" in launcher_args: + cli_disable_all = bool(launcher_args["visualizer_disable_all"]) + else: + cli_disable_all = bool(cli_explicit) and visualizers is not None and "none" in visualizers + + with contextlib.suppress(Exception): + visualizer_str = " ".join(visualizers) if visualizers else "" + settings = get_settings_manager() + settings.set_string("/isaaclab/visualizer/types", visualizer_str) + settings.set_bool("/isaaclab/visualizer/explicit", cli_explicit) + settings.set_bool("/isaaclab/visualizer/disable_all", cli_disable_all) + + # Sentinel: ``-1`` means ``--max_visible_envs`` was not passed (see ``SimulationContext``). + if "max_visible_envs" in launcher_args: + settings.set_int("/isaaclab/visualizer/max_visible_envs", int(launcher_args["max_visible_envs"])) + else: + settings.set_int("/isaaclab/visualizer/max_visible_envs", -1) + + @staticmethod + def _parse_visualizer_csv(value: str) -> list[str]: + """Parse visualizer list from a single comma-delimited CLI token.""" + valid = {"kit", "newton", "rerun", "viser", "none"} + token = (value or "").strip() + if not token: + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: empty string. Use a comma-separated list, e.g. --viz kit,newton." + ) + if " " in token: + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: spaces are not allowed. " + "Use a comma-separated list without spaces, e.g. --viz kit,newton,rerun,viser." + ) + + names = [item.strip().lower() for item in token.split(",")] + if any(not name for name in names): + raise argparse.ArgumentTypeError( + "Invalid --visualizer value: empty visualizer entry detected. " + "Use a comma-separated list without empty items." + ) + invalid = [name for name in names if name not in valid] + if invalid: + raise argparse.ArgumentTypeError( + f"Invalid --visualizer value(s): {', '.join(invalid)}. Valid options: {', '.join(sorted(valid))}." + ) + # De-duplicate while preserving order. + return list(dict.fromkeys(names)) + + @staticmethod + def _normalize_visualizer_intent(intent: Any) -> tuple[bool, bool]: + """Normalize and validate upstream config visualizer intent payload. + + The expected schema is: + ``{"has_any_visualizers": bool, "has_kit_visualizer": bool}``. + """ + if intent is None: + return False, False + if not isinstance(intent, dict): + raise ValueError("Invalid value for `visualizer_intent`: expected dict or None.") + + has_any = intent.get("has_any_visualizers", False) + has_kit = intent.get("has_kit_visualizer", False) + if not isinstance(has_any, bool) or not isinstance(has_kit, bool): + raise ValueError( + "Invalid `visualizer_intent` values: expected booleans for `has_any_visualizers` and " + "`has_kit_visualizer`." + ) + if has_kit and not has_any: + raise ValueError( + "Invalid `visualizer_intent`: `has_kit_visualizer=True` requires `has_any_visualizers=True`." + ) + return has_any, has_kit + def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwargs): """Create a `SimulationApp`_ instance based on the input settings. @@ -449,7 +451,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: arg_group.add_argument( "--visualizer", "--viz", - type=_parse_visualizer_csv, + type=AppLauncher._parse_visualizer_csv, action=ExplicitAction, default=None, help="Visualizer backends to enable as CSV (e.g., kit,newton,rerun,viser).", @@ -815,7 +817,9 @@ def _resolve_headless_settings(self, launcher_args: dict, livestream_arg: int, l def _resolve_visualizer_settings(self, launcher_args: dict) -> None: """Resolve visualizer CLI semantics and normalize selection.""" raw_visualizers = launcher_args.get("visualizer") - cfg_has_any, cfg_has_kit = _normalize_visualizer_intent(launcher_args.pop("visualizer_intent", None)) + cfg_has_any, cfg_has_kit = AppLauncher._normalize_visualizer_intent( + launcher_args.pop("visualizer_intent", None) + ) self._cfg_has_any_visualizers = cfg_has_any self._cfg_has_kit_visualizer = cfg_has_kit visualizer_explicit = bool(launcher_args.pop("visualizer_explicit", False)) @@ -825,7 +829,7 @@ def _resolve_visualizer_settings(self, launcher_args: dict) -> None: visualizer_types: list[str] = [] if raw_visualizers is not None: if isinstance(raw_visualizers, str): - visualizer_types = _parse_visualizer_csv(raw_visualizers) + visualizer_types = AppLauncher._parse_visualizer_csv(raw_visualizers) else: visualizer_types = [str(v).strip().lower() for v in raw_visualizers if str(v).strip()] @@ -1185,7 +1189,7 @@ def _set_animation_recording_settings(self, launcher_args: dict) -> None: def _set_visualizer_settings(self, launcher_args: dict) -> None: """Persist visualizer CLI flags and ``max_visible_envs`` override for :class:`SimulationContext`.""" - sync_visualizer_cli_settings_to_carb( + AppLauncher.sync_visualizer_cli_settings_to_carb( { **launcher_args, "visualizer_explicit": getattr(self, "_cli_visualizer_explicit", False), diff --git a/source/isaaclab/test/app/test_kwarg_launch.py b/source/isaaclab/test/app/test_kwarg_launch.py index ca64fc747b16..25fe56b69232 100644 --- a/source/isaaclab/test/app/test_kwarg_launch.py +++ b/source/isaaclab/test/app/test_kwarg_launch.py @@ -77,13 +77,13 @@ def _raise_settings_error(): def test_parse_visualizer_csv_accepts_comma_delimited_values(): - parsed = app_launcher_module._parse_visualizer_csv("kit,newton,rerun,viser") + parsed = app_launcher_module.AppLauncher._parse_visualizer_csv("kit,newton,rerun,viser") assert parsed == ["kit", "newton", "rerun", "viser"] def test_parse_visualizer_csv_rejects_spaces_between_entries(): with pytest.raises(argparse.ArgumentTypeError, match="spaces are not allowed"): - app_launcher_module._parse_visualizer_csv("kit, newton") + app_launcher_module.AppLauncher._parse_visualizer_csv("kit, newton") def test_resolve_visualizer_settings_rejects_none_with_others(): diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py index 22c1fca0d2ea..0f0c6e5404de 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py @@ -240,15 +240,15 @@ def launch_simulation( # Newton path without Kit: AppLauncher is skipped, so manually store the visualizer # selection in SettingsManager (works in standalone mode via plain dict) so that # SimulationContext._get_cli_visualizer_types() can find it. - from isaaclab.app.app_launcher import sync_visualizer_cli_settings_to_carb + from isaaclab.app import AppLauncher disable_all = "none" in visualizer_types if isinstance(launcher_args, argparse.Namespace): - sync_visualizer_cli_settings_to_carb( + AppLauncher.sync_visualizer_cli_settings_to_carb( {**vars(launcher_args), "visualizer_explicit": True, "visualizer_disable_all": disable_all} ) elif isinstance(launcher_args, dict): - sync_visualizer_cli_settings_to_carb( + AppLauncher.sync_visualizer_cli_settings_to_carb( {**launcher_args, "visualizer_explicit": True, "visualizer_disable_all": disable_all} ) From 7062481141d256e1f2bcbed516a2a25608fadd16 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Wed, 22 Apr 2026 23:34:00 +0000 Subject: [PATCH 30/37] filter visualization markers --- .../kit/kit_visualizer.py | 24 +++++++++++++++---- .../kit/kit_visualizer_cfg.py | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 684a97bb62f1..ca5117f62d09 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -128,6 +128,9 @@ def step(self, dt: float) -> None: settings.set_bool("/app/player/playSimulations", True) except (ImportError, AttributeError) as exc: logger.debug("[KitVisualizer] App update skipped: %s", exc) + # Markers (VisualizationMarkers) are often created or resized to num_envs only after the first + # simulation / debug-vis step; re-apply PointInstancer invisibleIds each step when partial viz is on. + self._refresh_partial_viz_point_instancers_if_needed() def close(self) -> None: """Close viewport resources and restore temporary state.""" @@ -394,9 +397,20 @@ def _apply_env_visibility(self, usd_stage, metadata: dict, visible_env_ids: list self._apply_visual_point_instancer_visibility(usd_stage, num_envs, visible) + def _refresh_partial_viz_point_instancers_if_needed(self) -> None: + """Re-apply ``invisibleIds`` for env-scaled `/Visuals` instancers (handles lazy marker creation).""" + if self._resolved_visible_env_ids is None or self._scene_data_provider is None: + return + usd_stage = self._scene_data_provider.get_usd_stage() + if usd_stage is None: + return + num_envs = int(self._scene_data_provider.get_metadata().get("num_envs", 0)) + if num_envs <= 0: + return + self._apply_visual_point_instancer_visibility(usd_stage, num_envs, set(self._resolved_visible_env_ids)) + def _apply_visual_point_instancer_visibility(self, usd_stage, num_envs: int, visible_env_ids: set[int]) -> None: """Set ``PointInstancer.invisibleIds`` for per-env `/Visuals` markers (e.g. velocity arrows).""" - self._point_instancer_invisible_ids_backup.clear() hidden = [i for i in range(num_envs) if i not in visible_env_ids] vt_hidden = Vt.Int64Array([int(i) for i in hidden]) for root_path in ("/Visuals", "/World/Visuals"): @@ -412,9 +426,11 @@ def _apply_visual_point_instancer_visibility(self, usd_stage, num_envs: int, vis continue path_str = prim.GetPath().pathString inv_attr = pi.GetInvisibleIdsAttr() - was_authored = inv_attr.HasAuthoredValue() - prev = inv_attr.Get() if was_authored else None - self._point_instancer_invisible_ids_backup[path_str] = (was_authored, prev) + # Record original authorship/value once per instancer for :meth:`_restore_env_visibility`. + if path_str not in self._point_instancer_invisible_ids_backup: + was_authored = inv_attr.HasAuthoredValue() + prev = inv_attr.Get() if was_authored else None + self._point_instancer_invisible_ids_backup[path_str] = (was_authored, prev) inv_attr.Set(vt_hidden) @staticmethod diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py index 1fde91f8aed7..342be3fc2c6f 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer_cfg.py @@ -24,7 +24,7 @@ class KitVisualizerCfg(VisualizerCfg): If ``None``, a default name (``"Visualizer Viewport"``) is used. """ - create_viewport: bool = True + create_viewport: bool = False """If ``True``, create a new viewport window; if ``False``, use the active viewport window.""" headless: bool = False From 663a0aa1d517b9f6f625d218d945dbe36ee51725 Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Wed, 22 Apr 2026 23:37:56 +0000 Subject: [PATCH 31/37] clean docs --- docs/source/features/visualization.rst | 28 ++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 423907a2bbf3..636da5742a28 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -152,10 +152,6 @@ There are 3 fields exposed in the ``VisualizerCfg`` for selecting environments f - ``randomly_sample_visible_envs`` (default ``True``): when ``visible_env_indices`` is unset and ``max_visible_envs`` is set, enables randomly sampling the selected envs. If disabled, the first ``max_visible_envs`` envs are selected. -.. note:: - ``max_visible_envs=None`` means no cap (every environment); random sampling does not run in that case. - The field default on ``VisualizerCfg`` is ``4``, not ``None``—override to ``None`` explicitly if you want all envs. - Also, there is a CLI arg ``--max_visible_envs`` that overrides ``VisualizerCfg.max_visible_envs`` for the run. .. _visualization-common-modes: @@ -328,6 +324,10 @@ Rerun Visualizer record_to_rrd="recording.rrd", # Path to save .rrd file (None = no recording) ) +Rerun startup uses the Python SDK through ``newton.viewer.ViewerRerun`` (no external ``rerun`` CLI process +management). If ``grpc_port`` is already active, Isaac Lab reuses that server. If ``web_port`` is occupied while +starting a new server, initialization fails with a clear port-conflict error. + Viser Visualizer ~~~~~~~~~~~~~~~~ @@ -343,6 +343,26 @@ server, allowing you to view and interact with the scene from any browser. - Recording to ``.viser`` format for replay - Environment filtering to control which environments are rendered +**Launch with Viser:** + +.. code-block:: bash + + ./isaaclab.sh -p source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env.py --viz viser + +**Configuration example:** + +.. code-block:: python + + from isaaclab_visualizers.viser import ViserVisualizerCfg + + visualizer_cfg = ViserVisualizerCfg( + port=8080, + open_browser=True, + label="Isaac Lab Simulation", + share=False, + max_worlds=64, + ) + **Configuration options:** - ``port`` (int, default ``8080``): Port of the local Viser web server. From 0a9e45ca8a305efeae44f16fe9b0917dcff5d9f9 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Wed, 22 Apr 2026 20:02:54 -0700 Subject: [PATCH 32/37] Adds presets to environment docs and fix doc build issues (#5360) # Description Adds a new column to the environments list with available presets defined for all example environments. Fixes recent doc build errors. ## Type of change - Documentation update ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Kelly Guo --- docs/conf.py | 1 + docs/source/overview/environments.rst | 787 +++++++++++++++----------- 2 files changed, 458 insertions(+), 330 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 2fc604aad10d..510fa3bc9280 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -186,6 +186,7 @@ "omni.timeline", "omni.ui", "gym", + "gymnasium", "skrl", "stable_baselines3", "rsl_rl", diff --git a/docs/source/overview/environments.rst b/docs/source/overview/environments.rst index 95c29a33e8ea..80c12abb522e 100644 --- a/docs/source/overview/environments.rst +++ b/docs/source/overview/environments.rst @@ -43,54 +43,52 @@ Classic Classic environments that are based on IsaacGymEnvs implementation of MuJoCo-style environments. .. table:: - :widths: 33 37 30 - - +------------------+-----------------------------+-------------------------------------------------------------------------+ - | World | Environment ID | Description | - +==================+=============================+=========================================================================+ - | |humanoid| | |humanoid-link| | Move towards a direction with the MuJoCo humanoid robot | - | | | | - | | |humanoid-direct-link| | | - +------------------+-----------------------------+-------------------------------------------------------------------------+ - | |ant| | |ant-link| | Move towards a direction with the MuJoCo ant robot | - | | | | - | | |ant-direct-link| | | - +------------------+-----------------------------+-------------------------------------------------------------------------+ - | |cartpole| | |cartpole-link| | Move the cart to keep the pole upwards in the classic cartpole control | - | | | | - | | |cartpole-direct-link| | | - +------------------+-----------------------------+-------------------------------------------------------------------------+ - | |cartpole| | |cartpole-rgb-link| | Move the cart to keep the pole upwards in the classic cartpole control | - | | | and perceptive inputs. Requires running with ``--enable_cameras``. | - | | |cartpole-depth-link| | | - | | | | - | | |cartpole-rgb-direct-link| | | - | | | | - | | |cartpole-depth-direct-link|| | - +------------------+-----------------------------+-------------------------------------------------------------------------+ - | |cartpole| | |cartpole-resnet-link| | Move the cart to keep the pole upwards in the classic cartpole control | - | | | based off of features extracted from perceptive inputs with pre-trained | - | | |cartpole-theia-link| | frozen vision encoders. Requires running with ``--enable_cameras``. | - +------------------+-----------------------------+-------------------------------------------------------------------------+ + :widths: 25 30 25 20 + + +------------------+-----------------------------+-------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +==================+=============================+=========================================================================+=======================+ + | |humanoid| | |humanoid-link| | Move towards a direction with the MuJoCo humanoid robot | ``newton``, ``physx`` | + | | | | ``ovphysx`` | + | | |humanoid-direct-link| | | | + +------------------+-----------------------------+-------------------------------------------------------------------------+-----------------------+ + | |ant| | |ant-link| | Move towards a direction with the MuJoCo ant robot | ``newton``, ``physx`` | + | | | | ``ovphysx`` | + | | |ant-direct-link| | | | + +------------------+-----------------------------+-------------------------------------------------------------------------+-----------------------+ + | |cartpole| | |cartpole-link| | Move the cart to keep the pole upwards in the classic cartpole control | ``newton``, ``physx`` | + | | | | ``ovphysx`` | + | | |cartpole-direct-link| | | | + +------------------+-----------------------------+-------------------------------------------------------------------------+-----------------------+ + | |cartpole| | |cartpole-camera-presets| | Move the cart to keep the pole upwards in the classic cartpole control | ``newton``, ``physx`` | + | | | and perceptive inputs. Select data type via ``presets=``. Requires | ``newton_renderer``, | + | | | running with ``--enable_cameras``. | ``ovrtx_renderer``, | + | | | | ``rgb``, ``depth``, | + | | | | ``albedo``, | + | | | | ``semantic_`` | + | | | | ``segmentation``, | + | | | | ``simple_shading_*`` | + +------------------+-----------------------------+-------------------------------------------------------------------------+-----------------------+ + | |cartpole| | |cartpole-resnet-link| | Move the cart to keep the pole upwards in the classic cartpole control | ``newton``, ``physx`` | + | | | based off of features extracted from perceptive inputs with pre-trained | | + | | |cartpole-theia-link| | frozen vision encoders. Requires running with ``--enable_cameras``. | | + +------------------+-----------------------------+-------------------------------------------------------------------------+-----------------------+ .. |humanoid| image:: ../_static/tasks/classic/humanoid.jpg .. |ant| image:: ../_static/tasks/classic/ant.jpg .. |cartpole| image:: ../_static/tasks/classic/cartpole.jpg -.. |humanoid-link| replace:: `Isaac-Humanoid-v0 `__ -.. |ant-link| replace:: `Isaac-Ant-v0 `__ -.. |cartpole-link| replace:: `Isaac-Cartpole-v0 `__ -.. |cartpole-rgb-link| replace:: `Isaac-Cartpole-RGB-v0 `__ -.. |cartpole-depth-link| replace:: `Isaac-Cartpole-Depth-v0 `__ -.. |cartpole-resnet-link| replace:: `Isaac-Cartpole-RGB-ResNet18-v0 `__ -.. |cartpole-theia-link| replace:: `Isaac-Cartpole-RGB-TheiaTiny-v0 `__ +.. |humanoid-link| replace:: `Isaac-Humanoid-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/humanoid/humanoid_env_cfg.py>`__ +.. |ant-link| replace:: `Isaac-Ant-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/ant/ant_env_cfg.py>`__ +.. |cartpole-link| replace:: `Isaac-Cartpole-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_env_cfg.py>`__ +.. |cartpole-camera-presets| replace:: `Isaac-Cartpole-Camera-Presets-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_camera_presets_env_cfg.py>`__ +.. |cartpole-resnet-link| replace:: `Isaac-Cartpole-RGB-ResNet18-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_camera_env_cfg.py>`__ +.. |cartpole-theia-link| replace:: `Isaac-Cartpole-RGB-TheiaTiny-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/classic/cartpole/cartpole_camera_env_cfg.py>`__ -.. |humanoid-direct-link| replace:: `Isaac-Humanoid-Direct-v0 `__ -.. |ant-direct-link| replace:: `Isaac-Ant-Direct-v0 `__ -.. |cartpole-direct-link| replace:: `Isaac-Cartpole-Direct-v0 `__ -.. |cartpole-rgb-direct-link| replace:: `Isaac-Cartpole-RGB-Camera-Direct-v0 `__ -.. |cartpole-depth-direct-link| replace:: `Isaac-Cartpole-Depth-Camera-Direct-v0 `__ +.. |humanoid-direct-link| replace:: `Isaac-Humanoid-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/humanoid/humanoid_env.py>`__ +.. |ant-direct-link| replace:: `Isaac-Ant-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/ant/ant_env.py>`__ +.. |cartpole-direct-link| replace:: `Isaac-Cartpole-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/cartpole/cartpole_env.py>`__ Manipulation ~~~~~~~~~~~~ @@ -105,83 +103,107 @@ for the lift-cube environment: * |lift-cube-ik-rel-link|: Franka arm with relative IK control .. table:: - :widths: 33 37 30 - - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | World | Environment ID | Description | - +=========================+==============================+=============================================================================+ - | |reach-franka| | |reach-franka-link| | Move the end-effector to a sampled target pose with the Franka robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |reach-ur10| | |reach-ur10-link| | Move the end-effector to a sampled target pose with the UR10 robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |deploy-reach-ur10e| | |deploy-reach-ur10e-link| | Move the end-effector to a sampled target pose with the UR10e robot | - | | | This policy has been deployed to a real robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |lift-cube| | |lift-cube-link| | Pick a cube and bring it to a sampled target position with the Franka robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |stack-cube| | |stack-cube-link| | Stack three cubes (bottom to top: blue, red, green) with the Franka robot. | - | | | Blueprint env used for the NVIDIA Isaac GR00T blueprint for synthetic | - | | |stack-cube-bp-link| | manipulation motion generation | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |surface-gripper| | |long-suction-link| | Stack three cubes (bottom to top: blue, red, green) | - | | | with the UR10 arm and long surface gripper | - | | |short-suction-link| | or short surface gripper. | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |cabi-franka| | |cabi-franka-link| | Grasp the handle of a cabinet's drawer and open it with the Franka robot | - | | | | - | | |franka-direct-link| | | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |cube-allegro| | |cube-allegro-link| | In-hand reorientation of a cube using Allegro hand | - | | | | - | | |allegro-direct-link| | | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |cube-shadow| | |cube-shadow-link| | In-hand reorientation of a cube using Shadow hand | - | | | | - | | |cube-shadow-ff-link| | | - | | | | - | | |cube-shadow-lstm-link| | | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |cube-shadow| | |cube-shadow-vis-link| | In-hand reorientation of a cube using Shadow hand using perceptive inputs. | - | | | Requires running with ``--enable_cameras``. | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |gr1_pick_place| | |gr1_pick_place-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |gr1_pp_waist| | |gr1_pp_waist-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | - | | | with waist degrees-of-freedom enables that provides a wider reach space. | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |g1_pick_place| | |g1_pick_place-link| | Pick up and place an object in a basket with a Unitree G1 humanoid robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |g1_pick_place_fixed| | |g1_pick_place_fixed-link| | Pick up and place an object in a basket with a Unitree G1 humanoid robot | - | | | with three-fingered hands. Robot is set up with the base fixed in place. | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |g1_pick_place_lm| | |g1_pick_place_lm-link| | Pick up and place an object in a basket with a Unitree G1 humanoid robot | - | | | with three-fingered hands and in-place locomanipulation capabilities | - | | | enabled (i.e. Robot lower body balances in-place while upper body is | - | | | controlled via Inverse Kinematics). | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |kuka-allegro-lift| | |kuka-allegro-lift-link| | Pick up a primitive shape on the table and lift it to target position. | - | | | Supports state, single-camera, and dual-camera observation modes via | - | | | ``presets=single_camera`` / ``presets=duo_camera`` (see RL table below). | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |kuka-allegro-reorient| | |kuka-allegro-reorient-link| | Pick up a primitive shape on the table and orient it to target pose. | - | | | Supports state, single-camera, and dual-camera observation modes via | - | | | ``presets=single_camera`` / ``presets=duo_camera`` (see RL table below). | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |galbot_stack| | |galbot_stack-link| | Stack three cubes (bottom to top: blue, red, green) with the left arm of | - | | | a Galbot humanoid robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |agibot_place_mug| | |agibot_place_mug-link| | Pick up and place a mug upright with a Agibot A2D humanoid robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |agibot_place_toy| | |agibot_place_toy-link| | Pick up and place an object in a box with a Agibot A2D humanoid robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |reach_openarm_bi| | |reach_openarm_bi-link| | Move the end-effector to sampled target poses with the OpenArm robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |reach_openarm_uni| | |reach_openarm_uni-link| | Move the end-effector to a sampled target pose with the OpenArm robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |lift_openarm_uni| | |lift_openarm_uni-link| | Pick a cube and bring it to a sampled target position with the OpenArm robot| - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ - | |cabi_openarm_uni| | |cabi_openarm_uni-link| | Grasp the handle of a cabinet's drawer and open it with the OpenArm robot | - +-------------------------+------------------------------+-----------------------------------------------------------------------------+ + :widths: 25 30 25 20 + + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +=========================+==============================+=============================================================================+=======================+ + | |reach-franka| | |reach-franka-link| | Move the end-effector to a sampled target pose with the Franka robot | ``newton``, ``physx`` | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |reach-ur10| | |reach-ur10-link| | Move the end-effector to a sampled target pose with the UR10 robot | ``newton``, ``physx`` | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |deploy-reach-ur10e| | |deploy-reach-ur10e-link| | Move the end-effector to a sampled target pose with the UR10e robot | | + | | | This policy has been deployed to a real robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |lift-cube| | |lift-cube-link| | Pick a cube and bring it to a sampled target position with the Franka robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |stack-cube| | |stack-cube-link| | Stack three cubes (bottom to top: blue, red, green) with the Franka robot. | | + | | | Blueprint env used for the NVIDIA Isaac GR00T blueprint for synthetic | | + | | |stack-cube-bp-link| | manipulation motion generation | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |surface-gripper| | |long-suction-link| | Stack three cubes (bottom to top: blue, red, green) | | + | | | with the UR10 arm and long surface gripper | | + | | |short-suction-link| | or short surface gripper. | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |cabi-franka| | |cabi-franka-link| | Grasp the handle of a cabinet's drawer and open it with the Franka robot | ``newton``, ``physx`` | + | | | | | + | | |franka-direct-link| | | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |cube-allegro| | |cube-allegro-link| | In-hand reorientation of a cube using Allegro hand | ``newton``, ``physx`` | + | | | | | + | | |allegro-direct-link| | | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |cube-shadow| | |cube-shadow-link| | In-hand reorientation of a cube using Shadow hand | ``newton``, ``physx`` | + | | | | | + | | |cube-shadow-ff-link| | | | + | | | | | + | | |cube-shadow-lstm-link| | | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |cube-shadow| | |cube-shadow-vis-link| | In-hand reorientation of a cube using Shadow hand using perceptive inputs. | ``newton``, ``physx`` | + | | | Requires running with ``--enable_cameras``. | ``newton_renderer``, | + | | | | ``ovrtx_renderer``, | + | | | | ``rgb``, ``depth``, | + | | | | ``albedo``, ``full``, | + | | | | ``semantic_`` | + | | | | ``segmentation``, | + | | | | ``simple_shading_*`` | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |gr1_pick_place| | |gr1_pick_place-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |gr1_pp_waist| | |gr1_pp_waist-link| | Pick up and place an object in a basket with a GR-1 humanoid robot | | + | | | with waist degrees-of-freedom enables that provides a wider reach space. | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |g1_pick_place| | |g1_pick_place-link| | Pick up and place an object in a basket with a Unitree G1 humanoid robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |g1_pick_place_fixed| | |g1_pick_place_fixed-link| | Pick up and place an object in a basket with a Unitree G1 humanoid robot | | + | | | with three-fingered hands. Robot is set up with the base fixed in place. | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |g1_pick_place_lm| | |g1_pick_place_lm-link| | Pick up and place an object in a basket with a Unitree G1 humanoid robot | | + | | | with three-fingered hands and in-place locomanipulation capabilities | | + | | | enabled (i.e. Robot lower body balances in-place while upper body is | | + | | | controlled via Inverse Kinematics). | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |kuka-allegro-lift| | |kuka-allegro-lift-link| | Pick up a primitive shape on the table and lift it to target position. | ``newton``, ``physx`` | + | | | Supports state, single-camera, and dual-camera observation modes via | ``single_camera``, | + | | | ``presets=single_camera`` / ``presets=duo_camera`` (see RL table below). | ``duo_camera``, | + | | | | ``newton_renderer``, | + | | | | ``ovrtx_renderer``, | + | | | | ``rgb{64,128,256}``, | + | | | | ``depth{..}``, | + | | | | ``albedo{..}``, | + | | | | ``semantic_`` | + | | | | ``segmentation{..}``, | + | | | | ``simple_shading_*`` | + | | | | ``{64,128,256}`` | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |kuka-allegro-reorient| | |kuka-allegro-reorient-link| | Pick up a primitive shape on the table and orient it to target pose. | ``newton``, ``physx`` | + | | | Supports state, single-camera, and dual-camera observation modes via | ``single_camera``, | + | | | ``presets=single_camera`` / ``presets=duo_camera`` (see RL table below). | ``duo_camera``, | + | | | | ``newton_renderer``, | + | | | | ``ovrtx_renderer``, | + | | | | ``rgb{64,128,256}``, | + | | | | ``depth{..}``, | + | | | | ``albedo{..}``, | + | | | | ``semantic_`` | + | | | | ``segmentation{..}``, | + | | | | ``simple_shading_*`` | + | | | | ``{64,128,256}`` | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |galbot_stack| | |galbot_stack-link| | Stack three cubes (bottom to top: blue, red, green) with the left arm of | | + | | | a Galbot humanoid robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |agibot_place_mug| | |agibot_place_mug-link| | Pick up and place a mug upright with a Agibot A2D humanoid robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |agibot_place_toy| | |agibot_place_toy-link| | Pick up and place an object in a box with a Agibot A2D humanoid robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |reach_openarm_bi| | |reach_openarm_bi-link| | Move the end-effector to sampled target poses with the OpenArm robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |reach_openarm_uni| | |reach_openarm_uni-link| | Move the end-effector to a sampled target pose with the OpenArm robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |lift_openarm_uni| | |lift_openarm_uni-link| | Pick a cube and bring it to a sampled target position with the OpenArm robot| | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |cabi_openarm_uni| | |cabi_openarm_uni-link| | Grasp the handle of a cabinet's drawer and open it with the OpenArm robot | | + +-------------------------+------------------------------+-----------------------------------------------------------------------------+-----------------------+ .. |reach-franka| image:: ../_static/tasks/manipulation/franka_reach.jpg .. |reach-ur10| image:: ../_static/tasks/manipulation/ur10_reach.jpg @@ -207,38 +229,38 @@ for the lift-cube environment: .. |lift_openarm_uni| image:: ../_static/tasks/manipulation/openarm_uni_lift.jpg .. |cabi_openarm_uni| image:: ../_static/tasks/manipulation/openarm_uni_open_drawer.jpg -.. |reach-franka-link| replace:: `Isaac-Reach-Franka-v0 `__ -.. |reach-ur10-link| replace:: `Isaac-Reach-UR10-v0 `__ -.. |deploy-reach-ur10e-link| replace:: `Isaac-Deploy-Reach-UR10e-v0 `__ -.. |lift-cube-link| replace:: `Isaac-Lift-Cube-Franka-v0 `__ -.. |lift-cube-ik-abs-link| replace:: `Isaac-Lift-Cube-Franka-IK-Abs-v0 `__ -.. |lift-cube-ik-rel-link| replace:: `Isaac-Lift-Cube-Franka-IK-Rel-v0 `__ -.. |cabi-franka-link| replace:: `Isaac-Open-Drawer-Franka-v0 `__ -.. |franka-direct-link| replace:: `Isaac-Franka-Cabinet-Direct-v0 `__ -.. |cube-allegro-link| replace:: `Isaac-Repose-Cube-Allegro-v0 `__ -.. |allegro-direct-link| replace:: `Isaac-Repose-Cube-Allegro-Direct-v0 `__ -.. |stack-cube-link| replace:: `Isaac-Stack-Cube-Franka-v0 `__ -.. |stack-cube-bp-link| replace:: `Isaac-Stack-Cube-Franka-IK-Rel-Blueprint-v0 `__ -.. |gr1_pick_place-link| replace:: `Isaac-PickPlace-GR1T2-Abs-v0 `__ -.. |g1_pick_place-link| replace:: `Isaac-PickPlace-G1-InspireFTP-Abs-v0 `__ -.. |g1_pick_place_fixed-link| replace:: `Isaac-PickPlace-FixedBaseUpperBodyIK-G1-Abs-v0 `__ -.. |g1_pick_place_lm-link| replace:: `Isaac-PickPlace-Locomanipulation-G1-Abs-v0 `__ -.. |long-suction-link| replace:: `Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 `__ -.. |short-suction-link| replace:: `Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0 `__ -.. |gr1_pp_waist-link| replace:: `Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0 `__ -.. |galbot_stack-link| replace:: `Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0 `__ -.. |kuka-allegro-lift-link| replace:: `Isaac-Dexsuite-Kuka-Allegro-Lift-v0 `__ -.. |kuka-allegro-reorient-link| replace:: `Isaac-Dexsuite-Kuka-Allegro-Reorient-v0 `__ -.. |cube-shadow-link| replace:: `Isaac-Repose-Cube-Shadow-Direct-v0 `__ -.. |cube-shadow-ff-link| replace:: `Isaac-Repose-Cube-Shadow-OpenAI-FF-Direct-v0 `__ -.. |cube-shadow-lstm-link| replace:: `Isaac-Repose-Cube-Shadow-OpenAI-LSTM-Direct-v0 `__ -.. |cube-shadow-vis-link| replace:: `Isaac-Repose-Cube-Shadow-Vision-Direct-v0 `__ -.. |agibot_place_mug-link| replace:: `Isaac-Place-Mug-Agibot-Left-Arm-RmpFlow-v0 `__ -.. |agibot_place_toy-link| replace:: `Isaac-Place-Toy2Box-Agibot-Right-Arm-RmpFlow-v0 `__ -.. |reach_openarm_bi-link| replace:: `Isaac-Reach-OpenArm-Bi-v0 `__ -.. |reach_openarm_uni-link| replace:: `Isaac-Reach-OpenArm-v0 `__ -.. |lift_openarm_uni-link| replace:: `Isaac-Lift-Cube-OpenArm-v0 `__ -.. |cabi_openarm_uni-link| replace:: `Isaac-Open-Drawer-OpenArm-v0 `__ +.. |reach-franka-link| replace:: `Isaac-Reach-Franka-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/franka/joint_pos_env_cfg.py>`__ +.. |reach-ur10-link| replace:: `Isaac-Reach-UR10-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/ur_10/joint_pos_env_cfg.py>`__ +.. |deploy-reach-ur10e-link| replace:: `Isaac-Deploy-Reach-UR10e-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/deploy/reach/config/ur_10e/joint_pos_env_cfg.py>`__ +.. |lift-cube-link| replace:: `Isaac-Lift-Cube-Franka-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/joint_pos_env_cfg.py>`__ +.. |lift-cube-ik-abs-link| replace:: `Isaac-Lift-Cube-Franka-IK-Abs-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_abs_env_cfg.py>`__ +.. |lift-cube-ik-rel-link| replace:: `Isaac-Lift-Cube-Franka-IK-Rel-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/franka/ik_rel_env_cfg.py>`__ +.. |cabi-franka-link| replace:: `Isaac-Open-Drawer-Franka-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/franka/joint_pos_env_cfg.py>`__ +.. |franka-direct-link| replace:: `Isaac-Franka-Cabinet-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/franka_cabinet/franka_cabinet_env.py>`__ +.. |cube-allegro-link| replace:: `Isaac-Repose-Cube-Allegro-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/inhand/config/allegro_hand/allegro_env_cfg.py>`__ +.. |allegro-direct-link| replace:: `Isaac-Repose-Cube-Allegro-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/allegro_hand/allegro_hand_env_cfg.py>`__ +.. |stack-cube-link| replace:: `Isaac-Stack-Cube-Franka-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_joint_pos_env_cfg.py>`__ +.. |stack-cube-bp-link| replace:: `Isaac-Stack-Cube-Franka-IK-Rel-Blueprint-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/franka/stack_ik_rel_blueprint_env_cfg.py>`__ +.. |gr1_pick_place-link| replace:: `Isaac-PickPlace-GR1T2-Abs-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_env_cfg.py>`__ +.. |g1_pick_place-link| replace:: `Isaac-PickPlace-G1-InspireFTP-Abs-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_unitree_g1_inspire_hand_env_cfg.py>`__ +.. |g1_pick_place_fixed-link| replace:: `Isaac-PickPlace-FixedBaseUpperBodyIK-G1-Abs-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/fixed_base_upper_body_ik_g1_env_cfg.py>`__ +.. |g1_pick_place_lm-link| replace:: `Isaac-PickPlace-Locomanipulation-G1-Abs-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/locomanipulation_g1_env_cfg.py>`__ +.. |long-suction-link| replace:: `Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py>`__ +.. |short-suction-link| replace:: `Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/ur10_gripper/stack_ik_rel_env_cfg.py>`__ +.. |gr1_pp_waist-link| replace:: `Isaac-PickPlace-GR1T2-WaistEnabled-Abs-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/pick_place/pickplace_gr1t2_waist_enabled_env_cfg.py>`__ +.. |galbot_stack-link| replace:: `Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/stack/config/galbot/stack_rmp_rel_env_cfg.py>`__ +.. |kuka-allegro-lift-link| replace:: `Isaac-Dexsuite-Kuka-Allegro-Lift-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py>`__ +.. |kuka-allegro-reorient-link| replace:: `Isaac-Dexsuite-Kuka-Allegro-Reorient-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/dexsuite/config/kuka_allegro/dexsuite_kuka_allegro_env_cfg.py>`__ +.. |cube-shadow-link| replace:: `Isaac-Repose-Cube-Shadow-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_env_cfg.py>`__ +.. |cube-shadow-ff-link| replace:: `Isaac-Repose-Cube-Shadow-OpenAI-FF-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_env_cfg.py>`__ +.. |cube-shadow-lstm-link| replace:: `Isaac-Repose-Cube-Shadow-OpenAI-LSTM-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_env_cfg.py>`__ +.. |cube-shadow-vis-link| replace:: `Isaac-Repose-Cube-Shadow-Vision-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand/shadow_hand_vision_env.py>`__ +.. |agibot_place_mug-link| replace:: `Isaac-Place-Mug-Agibot-Left-Arm-RmpFlow-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_upright_mug_rmp_rel_env_cfg.py>`__ +.. |agibot_place_toy-link| replace:: `Isaac-Place-Toy2Box-Agibot-Right-Arm-RmpFlow-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/place/config/agibot/place_toy2box_rmp_rel_env_cfg.py>`__ +.. |reach_openarm_bi-link| replace:: `Isaac-Reach-OpenArm-Bi-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/bimanual/joint_pos_env_cfg.py>`__ +.. |reach_openarm_uni-link| replace:: `Isaac-Reach-OpenArm-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/reach/config/openarm/unimanual/joint_pos_env_cfg.py>`__ +.. |lift_openarm_uni-link| replace:: `Isaac-Lift-Cube-OpenArm-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/lift/config/openarm/joint_pos_env_cfg.py>`__ +.. |cabi_openarm_uni-link| replace:: `Isaac-Open-Drawer-OpenArm-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/manipulation/cabinet/config/openarm/joint_pos_env_cfg.py>`__ Contact-rich Manipulation @@ -254,25 +276,25 @@ For example: * |factory-nut-link|: Nut-Bolt fastening with the Franka arm .. table:: - :widths: 33 37 30 - - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | World | Environment ID | Description | - +====================+=========================+=============================================================================+ - | |factory-peg| | |factory-peg-link| | Insert peg into the socket with the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |factory-gear| | |factory-gear-link| | Insert and mesh gear into the base with other gears, using the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |factory-nut| | |factory-nut-link| | Thread the nut onto the first 2 threads of the bolt, using the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ + :widths: 25 30 25 20 + + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +====================+=========================+=============================================================================+=======================+ + | |factory-peg| | |factory-peg-link| | Insert peg into the socket with the Franka robot | | + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |factory-gear| | |factory-gear-link| | Insert and mesh gear into the base with other gears, using the Franka robot | | + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |factory-nut| | |factory-nut-link| | Thread the nut onto the first 2 threads of the bolt, using the Franka robot | | + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ .. |factory-peg| image:: ../_static/tasks/factory/peg_insert.jpg .. |factory-gear| image:: ../_static/tasks/factory/gear_mesh.jpg .. |factory-nut| image:: ../_static/tasks/factory/nut_thread.jpg -.. |factory-peg-link| replace:: `Isaac-Factory-PegInsert-Direct-v0 `__ -.. |factory-gear-link| replace:: `Isaac-Factory-GearMesh-Direct-v0 `__ -.. |factory-nut-link| replace:: `Isaac-Factory-NutThread-Direct-v0 `__ +.. |factory-peg-link| replace:: `Isaac-Factory-PegInsert-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env_cfg.py>`__ +.. |factory-gear-link| replace:: `Isaac-Factory-GearMesh-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env_cfg.py>`__ +.. |factory-nut-link| replace:: `Isaac-Factory-NutThread-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/factory/factory_env_cfg.py>`__ AutoMate ~~~~~~~~ @@ -316,21 +338,21 @@ We provide environments for both disassembly and assembly. * To evaluate an assembly policy, we run the command ``python source/isaaclab_tasks/isaaclab_tasks/direct/automate/run_w_id.py --assembly_id=ASSEMBLY_ID --checkpoint=CHECKPOINT --log_eval``. The evaluation results are stored in ``evaluation_{ASSEMBLY_ID}.h5``. .. table:: - :widths: 33 37 30 + :widths: 25 30 25 20 - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | World | Environment ID | Description | - +====================+=========================+=============================================================================+ - | |disassembly| | |disassembly-link| | Lift a plug out of the socket with the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |assembly| | |assembly-link| | Insert a plug into its corresponding socket with the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +====================+=========================+=============================================================================+=======================+ + | |disassembly| | |disassembly-link| | Lift a plug out of the socket with the Franka robot | | + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |assembly| | |assembly-link| | Insert a plug into its corresponding socket with the Franka robot | | + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ .. |assembly| image:: ../_static/tasks/automate/00004.jpg .. |disassembly| image:: ../_static/tasks/automate/01053_disassembly.jpg -.. |assembly-link| replace:: `Isaac-AutoMate-Assembly-Direct-v0 `__ -.. |disassembly-link| replace:: `Isaac-AutoMate-Disassembly-Direct-v0 `__ +.. |assembly-link| replace:: `Isaac-AutoMate-Assembly-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/automate/assembly_env_cfg.py>`__ +.. |disassembly-link| replace:: `Isaac-AutoMate-Disassembly-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/automate/disassembly_env_cfg.py>`__ FORGE ~~~~~~~~ @@ -349,25 +371,25 @@ These tasks share the same task configurations and control options. You can swit * |forge-nut-link|: Nut-Bolt fastening with the Franka arm .. table:: - :widths: 33 37 30 - - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | World | Environment ID | Description | - +====================+=========================+=============================================================================+ - | |forge-peg| | |forge-peg-link| | Insert peg into the socket with the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |forge-gear| | |forge-gear-link| | Insert and mesh gear into the base with other gears, using the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ - | |forge-nut| | |forge-nut-link| | Thread the nut onto the first 2 threads of the bolt, using the Franka robot | - +--------------------+-------------------------+-----------------------------------------------------------------------------+ + :widths: 25 30 25 20 + + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +====================+=========================+=============================================================================+=======================+ + | |forge-peg| | |forge-peg-link| | Insert peg into the socket with the Franka robot | | + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |forge-gear| | |forge-gear-link| | Insert and mesh gear into the base with other gears, using the Franka robot | | + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |forge-nut| | |forge-nut-link| | Thread the nut onto the first 2 threads of the bolt, using the Franka robot | | + +--------------------+-------------------------+-----------------------------------------------------------------------------+-----------------------+ .. |forge-peg| image:: ../_static/tasks/factory/peg_insert.jpg .. |forge-gear| image:: ../_static/tasks/factory/gear_mesh.jpg .. |forge-nut| image:: ../_static/tasks/factory/nut_thread.jpg -.. |forge-peg-link| replace:: `Isaac-Forge-PegInsert-Direct-v0 `__ -.. |forge-gear-link| replace:: `Isaac-Forge-GearMesh-Direct-v0 `__ -.. |forge-nut-link| replace:: `Isaac-Forge-NutThread-Direct-v0 `__ +.. |forge-peg-link| replace:: `Isaac-Forge-PegInsert-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_env_cfg.py>`__ +.. |forge-gear-link| replace:: `Isaac-Forge-GearMesh-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_env_cfg.py>`__ +.. |forge-nut-link| replace:: `Isaac-Forge-NutThread-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/forge/forge_env_cfg.py>`__ Locomotion @@ -376,88 +398,88 @@ Locomotion Environments based on legged locomotion tasks. .. table:: - :widths: 33 37 30 - - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | World | Environment ID | Description | - +==============================+==============================================+==============================================================================+ - | |velocity-flat-anymal-b| | |velocity-flat-anymal-b-link| | Track a velocity command on flat terrain with the Anymal B robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-anymal-b| | |velocity-rough-anymal-b-link| | Track a velocity command on rough terrain with the Anymal B robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-anymal-c| | |velocity-flat-anymal-c-link| | Track a velocity command on flat terrain with the Anymal C robot | - | | | | - | | |velocity-flat-anymal-c-direct-link| | | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-anymal-c| | |velocity-rough-anymal-c-link| | Track a velocity command on rough terrain with the Anymal C robot | - | | | | - | | |velocity-rough-anymal-c-direct-link| | | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-anymal-d| | |velocity-flat-anymal-d-link| | Track a velocity command on flat terrain with the Anymal D robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-anymal-d| | |velocity-rough-anymal-d-link| | Track a velocity command on rough terrain with the Anymal D robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-unitree-a1| | |velocity-flat-unitree-a1-link| | Track a velocity command on flat terrain with the Unitree A1 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-unitree-a1| | |velocity-rough-unitree-a1-link| | Track a velocity command on rough terrain with the Unitree A1 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-unitree-go1| | |velocity-flat-unitree-go1-link| | Track a velocity command on flat terrain with the Unitree Go1 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-unitree-go1| | |velocity-rough-unitree-go1-link| | Track a velocity command on rough terrain with the Unitree Go1 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-unitree-go2| | |velocity-flat-unitree-go2-link| | Track a velocity command on flat terrain with the Unitree Go2 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-unitree-go2| | |velocity-rough-unitree-go2-link| | Track a velocity command on rough terrain with the Unitree Go2 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-spot| | |velocity-flat-spot-link| | Track a velocity command on flat terrain with the Boston Dynamics Spot robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-h1| | |velocity-flat-h1-link| | Track a velocity command on flat terrain with the Unitree H1 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-h1| | |velocity-rough-h1-link| | Track a velocity command on rough terrain with the Unitree H1 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-g1| | |velocity-flat-g1-link| | Track a velocity command on flat terrain with the Unitree G1 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-g1| | |velocity-rough-g1-link| | Track a velocity command on rough terrain with the Unitree G1 robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-flat-digit| | |velocity-flat-digit-link| | Track a velocity command on flat terrain with the Agility Digit robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |velocity-rough-digit| | |velocity-rough-digit-link| | Track a velocity command on rough terrain with the Agility Digit robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - | |tracking-loco-manip-digit| | |tracking-loco-manip-digit-link| | Track a root velocity and hand pose command with the Agility Digit robot | - +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+ - -.. |velocity-flat-anymal-b-link| replace:: `Isaac-Velocity-Flat-Anymal-B-v0 `__ -.. |velocity-rough-anymal-b-link| replace:: `Isaac-Velocity-Rough-Anymal-B-v0 `__ - -.. |velocity-flat-anymal-c-link| replace:: `Isaac-Velocity-Flat-Anymal-C-v0 `__ -.. |velocity-rough-anymal-c-link| replace:: `Isaac-Velocity-Rough-Anymal-C-v0 `__ - -.. |velocity-flat-anymal-c-direct-link| replace:: `Isaac-Velocity-Flat-Anymal-C-Direct-v0 `__ -.. |velocity-rough-anymal-c-direct-link| replace:: `Isaac-Velocity-Rough-Anymal-C-Direct-v0 `__ - -.. |velocity-flat-anymal-d-link| replace:: `Isaac-Velocity-Flat-Anymal-D-v0 `__ -.. |velocity-rough-anymal-d-link| replace:: `Isaac-Velocity-Rough-Anymal-D-v0 `__ - -.. |velocity-flat-unitree-a1-link| replace:: `Isaac-Velocity-Flat-Unitree-A1-v0 `__ -.. |velocity-rough-unitree-a1-link| replace:: `Isaac-Velocity-Rough-Unitree-A1-v0 `__ - -.. |velocity-flat-unitree-go1-link| replace:: `Isaac-Velocity-Flat-Unitree-Go1-v0 `__ -.. |velocity-rough-unitree-go1-link| replace:: `Isaac-Velocity-Rough-Unitree-Go1-v0 `__ - -.. |velocity-flat-unitree-go2-link| replace:: `Isaac-Velocity-Flat-Unitree-Go2-v0 `__ -.. |velocity-rough-unitree-go2-link| replace:: `Isaac-Velocity-Rough-Unitree-Go2-v0 `__ - -.. |velocity-flat-spot-link| replace:: `Isaac-Velocity-Flat-Spot-v0 `__ - -.. |velocity-flat-h1-link| replace:: `Isaac-Velocity-Flat-H1-v0 `__ -.. |velocity-rough-h1-link| replace:: `Isaac-Velocity-Rough-H1-v0 `__ - -.. |velocity-flat-g1-link| replace:: `Isaac-Velocity-Flat-G1-v0 `__ -.. |velocity-rough-g1-link| replace:: `Isaac-Velocity-Rough-G1-v0 `__ - -.. |velocity-flat-digit-link| replace:: `Isaac-Velocity-Flat-Digit-v0 `__ -.. |velocity-rough-digit-link| replace:: `Isaac-Velocity-Rough-Digit-v0 `__ -.. |tracking-loco-manip-digit-link| replace:: `Isaac-Tracking-LocoManip-Digit-v0 `__ + :widths: 25 30 25 20 + + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +==============================+==============================================+==============================================================================+=======================+ + | |velocity-flat-anymal-b| | |velocity-flat-anymal-b-link| | Track a velocity command on flat terrain with the Anymal B robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-anymal-b| | |velocity-rough-anymal-b-link| | Track a velocity command on rough terrain with the Anymal B robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-anymal-c| | |velocity-flat-anymal-c-link| | Track a velocity command on flat terrain with the Anymal C robot | ``newton``, ``physx`` | + | | | | | + | | |velocity-flat-anymal-c-direct-link| | | | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-anymal-c| | |velocity-rough-anymal-c-link| | Track a velocity command on rough terrain with the Anymal C robot | ``newton``, ``physx`` | + | | | | | + | | |velocity-rough-anymal-c-direct-link| | | | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-anymal-d| | |velocity-flat-anymal-d-link| | Track a velocity command on flat terrain with the Anymal D robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-anymal-d| | |velocity-rough-anymal-d-link| | Track a velocity command on rough terrain with the Anymal D robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-unitree-a1| | |velocity-flat-unitree-a1-link| | Track a velocity command on flat terrain with the Unitree A1 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-unitree-a1| | |velocity-rough-unitree-a1-link| | Track a velocity command on rough terrain with the Unitree A1 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-unitree-go1| | |velocity-flat-unitree-go1-link| | Track a velocity command on flat terrain with the Unitree Go1 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-unitree-go1| | |velocity-rough-unitree-go1-link| | Track a velocity command on rough terrain with the Unitree Go1 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-unitree-go2| | |velocity-flat-unitree-go2-link| | Track a velocity command on flat terrain with the Unitree Go2 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-unitree-go2| | |velocity-rough-unitree-go2-link| | Track a velocity command on rough terrain with the Unitree Go2 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-spot| | |velocity-flat-spot-link| | Track a velocity command on flat terrain with the Boston Dynamics Spot robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-h1| | |velocity-flat-h1-link| | Track a velocity command on flat terrain with the Unitree H1 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-h1| | |velocity-rough-h1-link| | Track a velocity command on rough terrain with the Unitree H1 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-g1| | |velocity-flat-g1-link| | Track a velocity command on flat terrain with the Unitree G1 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-g1| | |velocity-rough-g1-link| | Track a velocity command on rough terrain with the Unitree G1 robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-flat-digit| | |velocity-flat-digit-link| | Track a velocity command on flat terrain with the Agility Digit robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |velocity-rough-digit| | |velocity-rough-digit-link| | Track a velocity command on rough terrain with the Agility Digit robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + | |tracking-loco-manip-digit| | |tracking-loco-manip-digit-link| | Track a root velocity and hand pose command with the Agility Digit robot | ``newton``, ``physx`` | + +------------------------------+----------------------------------------------+------------------------------------------------------------------------------+-----------------------+ + +.. |velocity-flat-anymal-b-link| replace:: `Isaac-Velocity-Flat-Anymal-B-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/flat_env_cfg.py>`__ +.. |velocity-rough-anymal-b-link| replace:: `Isaac-Velocity-Rough-Anymal-B-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_b/rough_env_cfg.py>`__ + +.. |velocity-flat-anymal-c-link| replace:: `Isaac-Velocity-Flat-Anymal-C-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/flat_env_cfg.py>`__ +.. |velocity-rough-anymal-c-link| replace:: `Isaac-Velocity-Rough-Anymal-C-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_c/rough_env_cfg.py>`__ + +.. |velocity-flat-anymal-c-direct-link| replace:: `Isaac-Velocity-Flat-Anymal-C-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py>`__ +.. |velocity-rough-anymal-c-direct-link| replace:: `Isaac-Velocity-Rough-Anymal-C-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/anymal_c/anymal_c_env.py>`__ + +.. |velocity-flat-anymal-d-link| replace:: `Isaac-Velocity-Flat-Anymal-D-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/flat_env_cfg.py>`__ +.. |velocity-rough-anymal-d-link| replace:: `Isaac-Velocity-Rough-Anymal-D-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/anymal_d/rough_env_cfg.py>`__ + +.. |velocity-flat-unitree-a1-link| replace:: `Isaac-Velocity-Flat-Unitree-A1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/flat_env_cfg.py>`__ +.. |velocity-rough-unitree-a1-link| replace:: `Isaac-Velocity-Rough-Unitree-A1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/a1/rough_env_cfg.py>`__ + +.. |velocity-flat-unitree-go1-link| replace:: `Isaac-Velocity-Flat-Unitree-Go1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/flat_env_cfg.py>`__ +.. |velocity-rough-unitree-go1-link| replace:: `Isaac-Velocity-Rough-Unitree-Go1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go1/rough_env_cfg.py>`__ + +.. |velocity-flat-unitree-go2-link| replace:: `Isaac-Velocity-Flat-Unitree-Go2-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/flat_env_cfg.py>`__ +.. |velocity-rough-unitree-go2-link| replace:: `Isaac-Velocity-Rough-Unitree-Go2-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/rough_env_cfg.py>`__ + +.. |velocity-flat-spot-link| replace:: `Isaac-Velocity-Flat-Spot-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/spot/flat_env_cfg.py>`__ + +.. |velocity-flat-h1-link| replace:: `Isaac-Velocity-Flat-H1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/flat_env_cfg.py>`__ +.. |velocity-rough-h1-link| replace:: `Isaac-Velocity-Rough-H1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/h1/rough_env_cfg.py>`__ + +.. |velocity-flat-g1-link| replace:: `Isaac-Velocity-Flat-G1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/flat_env_cfg.py>`__ +.. |velocity-rough-g1-link| replace:: `Isaac-Velocity-Rough-G1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/g1/rough_env_cfg.py>`__ + +.. |velocity-flat-digit-link| replace:: `Isaac-Velocity-Flat-Digit-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/flat_env_cfg.py>`__ +.. |velocity-rough-digit-link| replace:: `Isaac-Velocity-Rough-Digit-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/digit/rough_env_cfg.py>`__ +.. |tracking-loco-manip-digit-link| replace:: `Isaac-Tracking-LocoManip-Digit-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/tracking/config/digit/loco_manip_env_cfg.py>`__ .. |velocity-flat-anymal-b| image:: ../_static/tasks/locomotion/anymal_b_flat.jpg .. |velocity-rough-anymal-b| image:: ../_static/tasks/locomotion/anymal_b_rough.jpg @@ -484,15 +506,15 @@ Navigation ~~~~~~~~~~ .. table:: - :widths: 33 37 30 + :widths: 25 30 25 20 - +----------------+---------------------+-----------------------------------------------------------------------------+ - | World | Environment ID | Description | - +================+=====================+=============================================================================+ - | |anymal_c_nav| | |anymal_c_nav-link| | Navigate towards a target x-y position and heading with the ANYmal C robot. | - +----------------+---------------------+-----------------------------------------------------------------------------+ + +----------------+---------------------+-----------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +================+=====================+=============================================================================+=======================+ + | |anymal_c_nav| | |anymal_c_nav-link| | Navigate towards a target x-y position and heading with the ANYmal C robot. | ``newton``, ``physx`` | + +----------------+---------------------+-----------------------------------------------------------------------------+-----------------------+ -.. |anymal_c_nav-link| replace:: `Isaac-Navigation-Flat-Anymal-C-v0 `__ +.. |anymal_c_nav-link| replace:: `Isaac-Navigation-Flat-Anymal-C-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/navigation/config/anymal_c/navigation_env_cfg.py>`__ .. |anymal_c_nav| image:: ../_static/tasks/navigation/anymal_c_nav.jpg @@ -505,18 +527,18 @@ Multirotor See the `drone_arl` folder and the ARL robot config (`ARL_ROBOT_1_CFG`) in the codebase for details. -.. |arl_robot_track_position_state_based-link| replace:: `Isaac-TrackPositionNoObstacles-ARL-Robot-1-v0 `__ +.. |arl_robot_track_position_state_based-link| replace:: `Isaac-TrackPositionNoObstacles-ARL-Robot-1-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/manager_based/drone_arl/track_position_state_based/config/arl_robot_1/track_position_state_based_env_cfg.py>`__ .. |arl_robot_track_position_state_based| image:: ../_static/tasks/drone_arl/arl_robot_1_track_position_state_based.jpg .. table:: - :widths: 33 37 30 + :widths: 25 30 25 20 - +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+ - | World | Environment ID | Description | - +========================================+=============================================+========================================================================================+ - | |arl_robot_track_position_state_based| | |arl_robot_track_position_state_based-link| | Setpoint position control for the ARL robot using the track_position_state_based task. | - +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+ + +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +========================================+=============================================+========================================================================================+=======================+ + | |arl_robot_track_position_state_based| | |arl_robot_track_position_state_based-link| | Setpoint position control for the ARL robot using the track_position_state_based task. | | + +----------------------------------------+---------------------------------------------+----------------------------------------------------------------------------------------+-----------------------+ Others @@ -532,24 +554,24 @@ Others For evaluation, the play script's command line input ``--real-time`` allows the interaction loop between the environment and the agent to run in real time, if possible. .. table:: - :widths: 33 37 30 - - +----------------+---------------------------+-----------------------------------------------------------------------------+ - | World | Environment ID | Description | - +================+===========================+=============================================================================+ - | |quadcopter| | |quadcopter-link| | Fly and hover the Crazyflie copter at a goal point by applying thrust. | - +----------------+---------------------------+-----------------------------------------------------------------------------+ - | |humanoid_amp| | |humanoid_amp_dance-link| | Move a humanoid robot by imitating different pre-recorded human animations | - | | | (Adversarial Motion Priors). | - | | |humanoid_amp_run-link| | | - | | | | - | | |humanoid_amp_walk-link| | | - +----------------+---------------------------+-----------------------------------------------------------------------------+ - -.. |quadcopter-link| replace:: `Isaac-Quadcopter-Direct-v0 `__ -.. |humanoid_amp_dance-link| replace:: `Isaac-Humanoid-AMP-Dance-Direct-v0 `__ -.. |humanoid_amp_run-link| replace:: `Isaac-Humanoid-AMP-Run-Direct-v0 `__ -.. |humanoid_amp_walk-link| replace:: `Isaac-Humanoid-AMP-Walk-Direct-v0 `__ + :widths: 25 30 25 20 + + +----------------+---------------------------+-----------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +================+===========================+=============================================================================+=======================+ + | |quadcopter| | |quadcopter-link| | Fly and hover the Crazyflie copter at a goal point by applying thrust. | | + +----------------+---------------------------+-----------------------------------------------------------------------------+-----------------------+ + | |humanoid_amp| | |humanoid_amp_dance-link| | Move a humanoid robot by imitating different pre-recorded human animations | | + | | | (Adversarial Motion Priors). | | + | | |humanoid_amp_run-link| | | | + | | | | | + | | |humanoid_amp_walk-link| | | | + +----------------+---------------------------+-----------------------------------------------------------------------------+-----------------------+ + +.. |quadcopter-link| replace:: `Isaac-Quadcopter-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/quadcopter/quadcopter_env.py>`__ +.. |humanoid_amp_dance-link| replace:: `Isaac-Humanoid-AMP-Dance-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/humanoid_amp/humanoid_amp_env_cfg.py>`__ +.. |humanoid_amp_run-link| replace:: `Isaac-Humanoid-AMP-Run-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/humanoid_amp/humanoid_amp_env_cfg.py>`__ +.. |humanoid_amp_walk-link| replace:: `Isaac-Humanoid-AMP-Walk-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/humanoid_amp/humanoid_amp_env_cfg.py>`__ .. |quadcopter| image:: ../_static/tasks/others/quadcopter.jpg .. |humanoid_amp| image:: ../_static/tasks/others/humanoid_amp.jpg @@ -684,17 +706,17 @@ Classic ~~~~~~~ .. table:: - :widths: 33 37 30 + :widths: 25 30 25 20 - +------------------------+------------------------------------+-----------------------------------------------------------------------------------------------------------------------+ - | World | Environment ID | Description | - +========================+====================================+=======================================================================================================================+ - | |cart-double-pendulum| | |cart-double-pendulum-direct-link| | Move the cart and the pendulum to keep the last one upwards in the classic inverted double pendulum on a cart control | - +------------------------+------------------------------------+-----------------------------------------------------------------------------------------------------------------------+ + +------------------------+------------------------------------+-----------------------------------------------------------------------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +========================+====================================+=======================================================================================================================+=======================+ + | |cart-double-pendulum| | |cart-double-pendulum-direct-link| | Move the cart and the pendulum to keep the last one upwards in the classic inverted double pendulum on a cart control | | + +------------------------+------------------------------------+-----------------------------------------------------------------------------------------------------------------------+-----------------------+ .. |cart-double-pendulum| image:: ../_static/tasks/classic/cart_double_pendulum.jpg -.. |cart-double-pendulum-direct-link| replace:: `Isaac-Cart-Double-Pendulum-Direct-v0 `__ +.. |cart-double-pendulum-direct-link| replace:: `Isaac-Cart-Double-Pendulum-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/cart_double_pendulum/cart_double_pendulum_env.py>`__ Manipulation ~~~~~~~~~~~~ @@ -702,17 +724,17 @@ Manipulation Environments based on fixed-arm manipulation tasks. .. table:: - :widths: 33 37 30 + :widths: 25 30 25 20 - +----------------------+--------------------------------+--------------------------------------------------------+ - | World | Environment ID | Description | - +======================+================================+========================================================+ - | |shadow-hand-over| | |shadow-hand-over-direct-link| | Passing an object from one hand over to the other hand | - +----------------------+--------------------------------+--------------------------------------------------------+ + +----------------------+--------------------------------+--------------------------------------------------------+-----------------------+ + | World | Environment ID | Description | Presets | + +======================+================================+========================================================+=======================+ + | |shadow-hand-over| | |shadow-hand-over-direct-link| | Passing an object from one hand over to the other hand | | + +----------------------+--------------------------------+--------------------------------------------------------+-----------------------+ .. |shadow-hand-over| image:: ../_static/tasks/manipulation/shadow_hand_over.jpg -.. |shadow-hand-over-direct-link| replace:: `Isaac-Shadow-Hand-Over-Direct-v0 `__ +.. |shadow-hand-over-direct-link| replace:: `Isaac-Shadow-Hand-Over-Direct-v0 <../../../source/isaaclab_tasks/isaaclab_tasks/direct/shadow_hand_over/shadow_hand_over_env.py>`__ | @@ -724,308 +746,368 @@ provided when running ``play.py`` or any inferencing workflows. These tasks prov inferencing, including reading from an already trained checkpoint and disabling runtime perturbations used for training. .. list-table:: - :widths: 33 25 19 25 + :widths: 28 20 13 22 17 * - **Task Name** - **Inference Task Name** - **Workflow** - **RL Library** + - **Presets** * - Isaac-Ant-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Ant-v0 - - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO), **skrl** (PPO), **sb3** (PPO) + - ``newton``, ``physx`` * - Isaac-Cart-Double-Pendulum-Direct-v0 - - Direct - **rl_games** (PPO), **skrl** (IPPO, PPO, MAPPO) + - * - Isaac-Cartpole-Camera-Showcase-Box-Box-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` * - Isaac-Cartpole-Camera-Showcase-Box-Discrete-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` * - Isaac-Cartpole-Camera-Showcase-Box-MultiDiscrete-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` * - Isaac-Cartpole-Camera-Showcase-Dict-Box-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` * - Isaac-Cartpole-Camera-Showcase-Dict-Discrete-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` * - Isaac-Cartpole-Camera-Showcase-Dict-MultiDiscrete-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` * - Isaac-Cartpole-Camera-Showcase-Tuple-Box-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` * - Isaac-Cartpole-Camera-Showcase-Tuple-Discrete-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` * - Isaac-Cartpole-Camera-Showcase-Tuple-MultiDiscrete-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **skrl** (PPO) - * - Isaac-Cartpole-Depth-Camera-Direct-v0 (Requires running with ``--enable_cameras``) + - ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer`` + * - Isaac-Cartpole-Camera-Presets-Direct-v0 (Requires running with ``--enable_cameras``) - - Direct - **rl_games** (PPO), **skrl** (PPO) - * - Isaac-Cartpole-Depth-v0 (Requires running with ``--enable_cameras``) - - - - Manager Based - - **rl_games** (PPO) + - ``newton``, ``physx``, ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer``, ``rgb``, ``depth``, ``albedo``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - Isaac-Cartpole-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO), **sb3** (PPO) - * - Isaac-Cartpole-RGB-Camera-Direct-v0 (Requires running with ``--enable_cameras``) - - - - Direct - - **rl_games** (PPO), **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-RGB-ResNet18-v0 (Requires running with ``--enable_cameras``) - - Manager Based - **rl_games** (PPO) + - ``newton``, ``physx`` * - Isaac-Cartpole-RGB-TheiaTiny-v0 (Requires running with ``--enable_cameras``) - - Manager Based - **rl_games** (PPO) - * - Isaac-Cartpole-RGB-v0 (Requires running with ``--enable_cameras``) - - - - Manager Based - - **rl_games** (PPO) + - ``newton``, ``physx`` * - Isaac-Cartpole-Showcase-Box-Box-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Box-Discrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Box-MultiDiscrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Dict-Box-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Dict-Discrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Dict-MultiDiscrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Discrete-Box-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Discrete-Discrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Discrete-MultiDiscrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-MultiDiscrete-Box-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-MultiDiscrete-Discrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-MultiDiscrete-MultiDiscrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Tuple-Box-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Tuple-Discrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-Showcase-Tuple-MultiDiscrete-Direct-v0 - - Direct - **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Cartpole-v0 - - Manager Based - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO), **sb3** (PPO) + - ``newton``, ``physx`` * - Isaac-Factory-GearMesh-Direct-v0 - - Direct - **rl_games** (PPO) + - * - Isaac-Factory-NutThread-Direct-v0 - - Direct - **rl_games** (PPO) + - * - Isaac-Factory-PegInsert-Direct-v0 - - Direct - **rl_games** (PPO) + - * - Isaac-AutoMate-Assembly-Direct-v0 - - Direct - **rl_games** (PPO) + - * - Isaac-AutoMate-Disassembly-Direct-v0 - - Direct - + - * - Isaac-Forge-GearMesh-Direct-v0 - - Direct - **rl_games** (PPO) + - * - Isaac-Forge-NutThread-Direct-v0 - - Direct - **rl_games** (PPO) + - * - Isaac-Forge-PegInsert-Direct-v0 - - Direct - **rl_games** (PPO) + - * - Isaac-Franka-Cabinet-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - * - Isaac-Humanoid-AMP-Dance-Direct-v0 - - Direct - **skrl** (AMP) + - * - Isaac-Humanoid-AMP-Run-Direct-v0 - - Direct - **skrl** (AMP) + - * - Isaac-Humanoid-AMP-Walk-Direct-v0 - - Direct - **skrl** (AMP) + - * - Isaac-Humanoid-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx``, ``ovphysx`` * - Isaac-Humanoid-v0 - - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO), **skrl** (PPO), **sb3** (PPO) + - ``newton``, ``physx`` * - Isaac-Lift-Cube-Franka-IK-Abs-v0 - - Manager Based - + - * - Isaac-Lift-Cube-Franka-IK-Rel-v0 - - Manager Based - + - * - Isaac-Lift-Cube-Franka-v0 - Isaac-Lift-Cube-Franka-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO), **rl_games** (PPO), **sb3** (PPO) + - * - Isaac-Lift-Teddy-Bear-Franka-IK-Abs-v0 - - Manager Based - + - * - Isaac-Tracking-LocoManip-Digit-v0 - Isaac-Tracking-LocoManip-Digit-Play-v0 - Manager Based - **rsl_rl** (PPO) + - ``newton``, ``physx`` * - Isaac-Navigation-Flat-Anymal-C-v0 - Isaac-Navigation-Flat-Anymal-C-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Open-Drawer-Franka-IK-Abs-v0 - - Manager Based - + - * - Isaac-Open-Drawer-Franka-IK-Rel-v0 - - Manager Based - + - * - Isaac-Open-Drawer-Franka-v0 - Isaac-Open-Drawer-Franka-Play-v0 - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Quadcopter-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - * - Isaac-Reach-Franka-IK-Abs-v0 - - Manager Based - + - * - Isaac-Reach-Franka-IK-Rel-v0 - - Manager Based - + - * - Isaac-Reach-Franka-OSC-v0 - Isaac-Reach-Franka-OSC-Play-v0 - Manager Based - **rsl_rl** (PPO) + - ``newton``, ``physx`` * - Isaac-Reach-Franka-v0 - Isaac-Reach-Franka-Play-v0 - Manager Based - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Reach-UR10-v0 - Isaac-Reach-UR10-Play-v0 - Manager Based - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Deploy-Reach-UR10e-v0 - Isaac-Deploy-Reach-UR10e-Play-v0 - Manager Based - **rsl_rl** (PPO) + - * - Isaac-Repose-Cube-Allegro-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Repose-Cube-Allegro-NoVelObs-v0 - Isaac-Repose-Cube-Allegro-NoVelObs-Play-v0 - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO), **skrl** (PPO) + - * - Isaac-Repose-Cube-Allegro-v0 - Isaac-Repose-Cube-Allegro-Play-v0 - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO), **skrl** (PPO) + - * - Isaac-Repose-Cube-Shadow-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Repose-Cube-Shadow-OpenAI-FF-Direct-v0 - - Direct - **rl_games** (FF), **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Repose-Cube-Shadow-OpenAI-LSTM-Direct-v0 - - Direct - **rl_games** (LSTM) + - ``newton``, ``physx`` * - Isaac-Repose-Cube-Shadow-Vision-Direct-v0 (Requires running with ``--enable_cameras``) - Isaac-Repose-Cube-Shadow-Vision-Direct-Play-v0 (Requires running with ``--enable_cameras``) - Direct - **rsl_rl** (PPO), **rl_games** (VISION) + - ``newton``, ``physx``, ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer``, ``rgb``, ``depth``, ``albedo``, ``full``, ``semantic_segmentation``, ``simple_shading_constant_diffuse``, ``simple_shading_diffuse_mdl``, ``simple_shading_full_mdl`` * - Isaac-Shadow-Hand-Over-Direct-v0 - - Direct - **rl_games** (PPO), **skrl** (IPPO, PPO, MAPPO) + - * - Isaac-Stack-Cube-Franka-IK-Rel-v0 - - Manager Based - + - * - Isaac-Dexsuite-Kuka-Allegro-Lift-v0 Camera variants (requires ``--enable_cameras``): @@ -1041,6 +1123,7 @@ inferencing, including reading from an already trained checkpoint and disabling - Isaac-Dexsuite-Kuka-Allegro-Lift-Play-v0 - Manager Based - **rl_games** (PPO), **rsl_rl** (PPO) + - ``newton``, ``physx``, ``single_camera``, ``duo_camera``, ``state``, ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer``, ``rgb64``, ``rgb128``, ``rgb256``, ``depth64``, ``depth128``, ``depth256``, ``albedo64``, ``albedo128``, ``albedo256``, ``semantic_segmentation64``, ``semantic_segmentation128``, ``semantic_segmentation256``, ``simple_shading_constant_diffuse64``, ``simple_shading_constant_diffuse128``, ``simple_shading_constant_diffuse256``, ``simple_shading_diffuse_mdl64``, ``simple_shading_diffuse_mdl128``, ``simple_shading_diffuse_mdl256``, ``simple_shading_full_mdl64``, ``simple_shading_full_mdl128``, ``simple_shading_full_mdl256`` * - Isaac-Dexsuite-Kuka-Allegro-Reorient-v0 Camera variants (requires ``--enable_cameras``): @@ -1053,176 +1136,220 @@ inferencing, including reading from an already trained checkpoint and disabling - Isaac-Dexsuite-Kuka-Allegro-Reorient-Play-v0 - Manager Based - **rl_games** (PPO), **rsl_rl** (PPO) + - ``newton``, ``physx``, ``single_camera``, ``duo_camera``, ``state``, ``newton_renderer``, ``ovrtx_renderer``, ``isaacsim_rtx_renderer``, ``rgb64``, ``rgb128``, ``rgb256``, ``depth64``, ``depth128``, ``depth256``, ``albedo64``, ``albedo128``, ``albedo256``, ``semantic_segmentation64``, ``semantic_segmentation128``, ``semantic_segmentation256``, ``simple_shading_constant_diffuse64``, ``simple_shading_constant_diffuse128``, ``simple_shading_constant_diffuse256``, ``simple_shading_diffuse_mdl64``, ``simple_shading_diffuse_mdl128``, ``simple_shading_diffuse_mdl256``, ``simple_shading_full_mdl64``, ``simple_shading_full_mdl128``, ``simple_shading_full_mdl256`` * - Isaac-Stack-Cube-Franka-v0 - - Manager Based - + - * - Isaac-Stack-Cube-Instance-Randomize-Franka-IK-Rel-v0 - - Manager Based - + - * - Isaac-Stack-Cube-Instance-Randomize-Franka-v0 - - Manager Based - + - * - Isaac-PickPlace-G1-InspireFTP-Abs-v0 - - Manager Based - + - * - Isaac-Stack-Cube-UR10-Long-Suction-IK-Rel-v0 - - Manager Based - + - * - Isaac-Stack-Cube-UR10-Short-Suction-IK-Rel-v0 - - Manager Based - + - * - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0 - - Manager Based - + - * - Isaac-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow-v0 - - Manager Based - + - * - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-v0 - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-Play-v0 - Manager Based - + - * - Isaac-Place-Mug-Agibot-Left-Arm-RmpFlow-v0 - - Manager Based - + - * - Isaac-Place-Toy2Box-Agibot-Right-Arm-RmpFlow-v0 - - Manager Based - + - * - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-RmpFlow-v0 - - Manager Based - + - * - Isaac-Stack-Cube-Galbot-Right-Arm-Suction-RmpFlow-v0 - - Manager Based - + - * - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-v0 - Isaac-Stack-Cube-Galbot-Left-Arm-Gripper-Visuomotor-Play-v0 - Manager Based - + - * - Isaac-Place-Mug-Agibot-Left-Arm-RmpFlow-v0 - - Manager Based - + - * - Isaac-Place-Toy2Box-Agibot-Right-Arm-RmpFlow-v0 - - Manager Based - + - * - Isaac-Velocity-Flat-Anymal-B-v0 - Isaac-Velocity-Flat-Anymal-B-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-Anymal-C-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - * - Isaac-Velocity-Flat-Anymal-C-v0 - Isaac-Velocity-Flat-Anymal-C-Play-v0 - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-Anymal-D-v0 - Isaac-Velocity-Flat-Anymal-D-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-Cassie-v0 - Isaac-Velocity-Flat-Cassie-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - * - Isaac-Velocity-Flat-Digit-v0 - Isaac-Velocity-Flat-Digit-Play-v0 - Manager Based - **rsl_rl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-G1-v0 - Isaac-Velocity-Flat-G1-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-H1-v0 - Isaac-Velocity-Flat-H1-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-Spot-v0 - Isaac-Velocity-Flat-Spot-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-Unitree-A1-v0 - Isaac-Velocity-Flat-Unitree-A1-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO), **sb3** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-Unitree-Go1-v0 - Isaac-Velocity-Flat-Unitree-Go1-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Flat-Unitree-Go2-v0 - Isaac-Velocity-Flat-Unitree-Go2-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-Anymal-B-v0 - Isaac-Velocity-Rough-Anymal-B-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-Anymal-C-Direct-v0 - - Direct - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - * - Isaac-Velocity-Rough-Anymal-C-v0 - Isaac-Velocity-Rough-Anymal-C-Play-v0 - Manager Based - **rl_games** (PPO), **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-Anymal-D-v0 - Isaac-Velocity-Rough-Anymal-D-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-Cassie-v0 - Isaac-Velocity-Rough-Cassie-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - * - Isaac-Velocity-Rough-Digit-v0 - Isaac-Velocity-Rough-Digit-Play-v0 - Manager Based - **rsl_rl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-G1-v0 - Isaac-Velocity-Rough-G1-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-H1-v0 - Isaac-Velocity-Rough-H1-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-Unitree-A1-v0 - Isaac-Velocity-Rough-Unitree-A1-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO), **sb3** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-Unitree-Go1-v0 - Isaac-Velocity-Rough-Unitree-Go1-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Velocity-Rough-Unitree-Go2-v0 - Isaac-Velocity-Rough-Unitree-Go2-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO) + - ``newton``, ``physx`` * - Isaac-Reach-OpenArm-Bi-v0 - Isaac-Reach-OpenArm-Bi-Play-v0 - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO) + - * - Isaac-Reach-OpenArm-v0 - Isaac-Reach-OpenArm-Play-v0 - Manager Based - **rsl_rl** (PPO), **skrl** (PPO), **rl_games** (PPO) + - * - Isaac-Lift-Cube-OpenArm-v0 - Isaac-Lift-Cube-OpenArm-Play-v0 - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO) + - * - Isaac-Open-Drawer-OpenArm-v0 - Isaac-Open-Drawer-OpenArm-Play-v0 - Manager Based - **rsl_rl** (PPO), **rl_games** (PPO) + - From 33f8f3ac41b58243ba8d23a8de52e7b7c5befd34 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Wed, 22 Apr 2026 21:20:04 -0700 Subject: [PATCH 33/37] Refactors Newton XformPrimView: proper local poses, warp-native API, and shared contract tests (#5179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description ### Summary Rewrites the Newton `XformPrimView` from scratch with correct local-pose semantics, a clean site-based architecture, and a shared test contract that enforces the same invariants across all backends (USD, Fabric, Newton). **Key changes:** - **Fix local poses**: `get_local_poses` / `set_local_poses` now correctly compute parent-relative transforms on GPU (`inv(parent_world) * prim_world`) instead of incorrectly returning world poses - **Fix set_world_poses**: Updates `site_local` offset instead of writing `body_q` directly (which would move the parent body) - **Guard against misuse**: Raises `ValueError` if prim path resolves to a physics body or collision shape — XformPrimView is for non-physics child prims only (cameras, sensors, markers) - **Warp-native API**: All inputs/outputs are `wp.array` — no torch/list conversion overhead - **Factory dispatch**: `from isaaclab.sim.views import XformPrimView` now auto-selects the correct backend (USD, Fabric, Newton) via `XformPrimViewFactory` - **Composition over inheritance**: PhysX `FabricXformPrimView` uses composition (`self._usd_view`) instead of inheriting from `UsdXformPrimView` - **Explicit class names**: `UsdXformPrimView`, `FabricXformPrimView`, `NewtonSiteXformPrimView` — no more ambiguous `XformPrimView` in every package - **Shared contract tests**: 16 test functions in `xform_contract_tests.py` that any backend imports and runs by providing a `view_factory` fixture - **Benchmark updates**: Both benchmark scripts support Newton, use warp-native arrays, and include per-backend round-trip verification ### Type of change - [x] Bug fix (Newton local poses were fundamentally broken — `local == world`) - [x] New feature (shared contract test infrastructure, factory dispatch) - [x] Breaking change (Newton `XformPrimView` renamed to `NewtonSiteXformPrimView`, PhysX to `FabricXformPrimView`; indices parameter changed from `Sequence[int]` to `wp.array`) - [x] Documentation update ### Expected failures - `test_set_world_updates_local[cuda:0]` in Fabric — pre-existing limitation: `set_world_poses` writes to `omni:fabric:worldMatrix` but `get_local_poses` reads from USD, so local poses are stale after a Fabric world write. This will be fixed by the Fabric backend PR (#4923) which adds `omni:fabric:localMatrix` support. ### Test results | Backend | Passed | Failed | Skipped | |---|---|---|---| | Newton | 40 | 0 | 0 | | USD | 45 | 0 | 0 | | Fabric | 15 | 1 (xfail) | 16 (CPU) | | Camera | 20 | 0 | 0 | | TiledCamera | 61 | 0 | 0 | | RayCaster | 5 | 0 | 0 | ### Benchmark (1024 prims, 50 iterations, RTX 5090) ``` ======================================================================================================================== BENCHMARK RESULTS: 1024 prims, 50 iterations ======================================================================================================================== Operation Isaaclab Usd (ms) Isaaclab Fabric (ms) Isaaclab Newton Site (ms) ------------------------------------------------------------------------------------------------------------------------ Initialization 3.7168 3.6596 39.0608 Get World Poses 6.6730 0.0296 0.0180 Set World Poses 15.5574 0.0640 0.0186 Get Local Poses 4.6086 4.5637 0.0216 Set Local Poses 6.4680 6.6221 0.0218 Get Both (World+Local) 12.1240 4.7361 0.0374 Interleaved World Set->Get 23.4141 0.1050 0.0344 ======================================================================================================================== Total 72.5619 19.7800 39.2126 ======================================================================================================================== ``` ### Checklist - [x] I have read and understood the contribution guidelines - [ ] I have run the pre-commit checks with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Signed-off-by: Kelly Guo Co-authored-by: Antoine Richard Co-authored-by: Kelly Guo --- docs/source/api/lab/isaaclab.sim.views.rst | 22 +- .../migration/migrating_to_isaaclab_3-0.rst | 43 + .../core-concepts/scene_data_providers.rst | 2 +- .../04_sensors/add_sensors_on_robot.rst | 8 +- .../benchmarks/benchmark_view_comparison.py | 629 +++---- .../benchmarks/benchmark_xform_prim_view.py | 783 +++------ scripts/demos/sensors/raycaster_sensor.py | 2 +- source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 49 + .../isaaclab/scene/interactive_scene.py | 8 +- source/isaaclab/isaaclab/sensors/__init__.py | 2 +- .../isaaclab/sensors/camera/camera.py | 72 +- .../isaaclab/sensors/camera/camera_cfg.py | 2 +- .../ray_caster/multi_mesh_ray_caster.py | 32 +- .../multi_mesh_ray_caster_camera.py | 5 +- .../sensors/ray_caster/ray_cast_utils.py | 49 - .../isaaclab/sensors/ray_caster/ray_caster.py | 156 +- .../sensors/ray_caster/ray_caster_camera.py | 43 +- .../sensors/ray_caster/ray_caster_cfg.py | 32 +- .../isaaclab/isaaclab/sensors/sensor_base.py | 71 + source/isaaclab/isaaclab/sim/__init__.pyi | 11 +- .../isaaclab/sim/spawners/__init__.pyi | 4 +- .../sim/spawners/sensors/__init__.pyi | 6 +- .../isaaclab/sim/spawners/sensors/sensors.py | 43 + .../sim/spawners/sensors/sensors_cfg.py | 12 + .../isaaclab/isaaclab/sim/views/__init__.pyi | 8 + .../isaaclab/sim/views/base_frame_view.py | 108 ++ .../isaaclab/isaaclab/sim/views/frame_view.py | 48 + .../isaaclab/sim/views/usd_frame_view.py | 359 ++++ .../isaaclab/sim/views/xform_prim_view.py | 1136 +----------- .../sensors/check_multi_mesh_ray_caster.py | 2 +- .../isaaclab/test/sensors/test_ray_caster.py | 78 + .../test/sim/frame_view_contract_utils.py | 359 ++++ .../test/sim/test_views_xform_prim.py | 1531 ++--------------- .../test/terrains/check_terrain_importer.py | 4 +- .../test/terrains/test_terrain_importer.py | 4 +- .../test/sensors/test_visuotactile_sensor.py | 3 +- .../locomanipulation_sdg/scene_utils.py | 14 +- source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 20 +- .../isaaclab_newton/sim/__init__.py | 10 + .../isaaclab_newton/sim/__init__.pyi | 10 + .../isaaclab_newton/sim/views/__init__.py | 10 + .../isaaclab_newton/sim/views/__init__.pyi | 10 + .../sim/views/newton_site_frame_view.py | 939 ++++++++++ source/isaaclab_newton/test/sim/__init__.py | 4 + .../test/sim/test_views_xform_prim_newton.py | 198 +++ source/isaaclab_physx/config/extension.toml | 2 +- source/isaaclab_physx/docs/CHANGELOG.rst | 16 + .../physx_scene_data_provider.py | 18 +- .../isaaclab_physx/sim/__init__.pyi | 2 + .../isaaclab_physx/sim/views/__init__.py | 10 + .../isaaclab_physx/sim/views/__init__.pyi | 10 + .../sim/views/fabric_frame_view.py | 403 +++++ source/isaaclab_physx/test/sim/__init__.py | 4 + .../test/sim/test_views_xform_prim_fabric.py | 105 ++ source/isaaclab_tasks/config/extension.toml | 2 +- source/isaaclab_tasks/docs/CHANGELOG.rst | 15 + .../pick_place/mdp/terminations.py | 2 +- .../isaaclab_teleop/test/test_oxr_device.py | 8 +- 60 files changed, 3849 insertions(+), 3693 deletions(-) delete mode 100644 source/isaaclab/isaaclab/sensors/ray_caster/ray_cast_utils.py create mode 100644 source/isaaclab/isaaclab/sim/views/base_frame_view.py create mode 100644 source/isaaclab/isaaclab/sim/views/frame_view.py create mode 100644 source/isaaclab/isaaclab/sim/views/usd_frame_view.py create mode 100644 source/isaaclab/test/sim/frame_view_contract_utils.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/views/__init__.py create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/views/__init__.pyi create mode 100644 source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py create mode 100644 source/isaaclab_newton/test/sim/__init__.py create mode 100644 source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sim/views/__init__.py create mode 100644 source/isaaclab_physx/isaaclab_physx/sim/views/__init__.pyi create mode 100644 source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py create mode 100644 source/isaaclab_physx/test/sim/__init__.py create mode 100644 source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py diff --git a/docs/source/api/lab/isaaclab.sim.views.rst b/docs/source/api/lab/isaaclab.sim.views.rst index 3a5f9bdecfe9..e06c4e54a246 100644 --- a/docs/source/api/lab/isaaclab.sim.views.rst +++ b/docs/source/api/lab/isaaclab.sim.views.rst @@ -7,11 +7,27 @@ .. autosummary:: - XformPrimView + BaseFrameView + UsdFrameView + FrameView -XForm Prim View +Base Frame View --------------- -.. autoclass:: XformPrimView +.. autoclass:: BaseFrameView + :members: + :show-inheritance: + +USD Frame View +-------------- + +.. autoclass:: UsdFrameView + :members: + :show-inheritance: + +Frame View +---------- + +.. autoclass:: FrameView :members: :show-inheritance: diff --git a/docs/source/migration/migrating_to_isaaclab_3-0.rst b/docs/source/migration/migrating_to_isaaclab_3-0.rst index 6855a4fda28a..37306fcbae39 100644 --- a/docs/source/migration/migrating_to_isaaclab_3-0.rst +++ b/docs/source/migration/migrating_to_isaaclab_3-0.rst @@ -98,6 +98,49 @@ The following classes have been moved to ``isaaclab_physx``: installation steps are required. +Renaming of ``XformPrimView`` to ``FrameView`` +----------------------------------------------- + +Isaac Lab's ``XformPrimView`` and related classes have been renamed to ``FrameView`` to +better reflect their purpose and avoid confusion with Isaac Sim's ``XFormPrim`` class +hierarchy. The old ``XformPrimView`` name is kept as a deprecated alias. + +The rename applies across all backends: + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Isaac Lab 2.x + - Isaac Lab 3.0 + * - ``BaseXformPrimView`` + - :class:`~isaaclab.sim.views.BaseFrameView` + * - ``UsdXformPrimView`` + - :class:`~isaaclab.sim.views.UsdFrameView` + * - ``XformPrimView`` + - :class:`~isaaclab.sim.views.FrameView` + * - ``FabricXformPrimView`` + - :class:`~isaaclab_physx.sim.views.FabricFrameView` + * - ``NewtonSiteXformPrimView`` + - :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` + +For most users, the only change needed is updating imports: + +.. code-block:: python + + # Before + from isaaclab.sim.views import XformPrimView + + # After + from isaaclab.sim.views import FrameView + +The :class:`~isaaclab.sim.views.FrameView` factory automatically dispatches to the correct +backend (:class:`~isaaclab_physx.sim.views.FabricFrameView` for PhysX, +:class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` for Newton) based on the active +physics backend. The deprecated ``XformPrimView`` alias continues to work but will be +removed in a future release. + + Unchanged Imports ----------------- diff --git a/docs/source/overview/core-concepts/scene_data_providers.rst b/docs/source/overview/core-concepts/scene_data_providers.rst index 684dfcefcbef..46244317ba2e 100644 --- a/docs/source/overview/core-concepts/scene_data_providers.rst +++ b/docs/source/overview/core-concepts/scene_data_providers.rst @@ -55,7 +55,7 @@ Newton-based visualizers (Newton, Rerun, Viser) require a Newton model/state to The sync pipeline: 1. Reads transforms from PhysX ``RigidBodyView`` (fast tensor API) -2. Falls back to ``XformPrimView`` for bodies not covered by the rigid body view +2. Falls back to :class:`~isaaclab.sim.views.FrameView` for bodies not covered by the rigid body view 3. Converts and writes merged poses into the Newton state via Warp kernels Newton Scene Data Provider diff --git a/docs/source/tutorials/04_sensors/add_sensors_on_robot.rst b/docs/source/tutorials/04_sensors/add_sensors_on_robot.rst index 3d9f40667b62..85383a876ed4 100644 --- a/docs/source/tutorials/04_sensors/add_sensors_on_robot.rst +++ b/docs/source/tutorials/04_sensors/add_sensors_on_robot.rst @@ -93,11 +93,11 @@ Height scanner The height-scanner is implemented as a virtual sensor using the NVIDIA Warp ray-casting kernels. Through the :class:`sensors.RayCasterCfg`, we can specify the pattern of rays to cast and the -meshes against which to cast the rays. Since they are virtual sensors, there is no corresponding -prim created in the scene for them. Instead they are attached to a prim in the scene, which is -used to specify the location of the sensor. +meshes against which to cast the rays. By default, :attr:`~sensors.RayCasterCfg.spawn` creates +a plain USD Xform at :attr:`~sensors.RayCasterCfg.prim_path` to serve as the sensor's +attachment frame, similar to how :class:`sensors.CameraCfg` spawns a Camera prim. -For this tutorial, the ray-cast based height scanner is attached to the base frame of the robot. +For this tutorial, the ray-cast based height scanner is attached under the base frame of the robot. The pattern of rays is specified using the :attr:`~sensors.RayCasterCfg.pattern` attribute. For a uniform grid pattern, we specify the pattern using :class:`~sensors.patterns.GridPatternCfg`. Since we only care about the height information, we do not need to consider the roll and pitch diff --git a/scripts/benchmarks/benchmark_view_comparison.py b/scripts/benchmarks/benchmark_view_comparison.py index 8f2b60c49077..a637f687803e 100644 --- a/scripts/benchmarks/benchmark_view_comparison.py +++ b/scripts/benchmarks/benchmark_view_comparison.py @@ -3,54 +3,50 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Benchmark script comparing XformPrimView vs PhysX RigidBodyView for transform operations. +"""Benchmark script comparing FrameView backends and PhysX RigidBodyView. -This script tests the performance of batched transform operations using: +Compares batched transform operation performance across: -- Isaac Lab's XformPrimView (USD-based) -- Isaac Lab's XformPrimView (Fabric-based) -- PhysX RigidBodyView (PhysX tensors-based, as used in RigidObject) - -Note: - XformPrimView operates on USD attributes directly (useful for non-physics prims), - or on Fabric attributes when Fabric is enabled. - while RigidBodyView requires rigid body physics components and operates on PhysX tensors. - This benchmark helps understand the performance trade-offs between the two approaches. +- **USD** (baseline): Isaac Lab's FrameView via USD XformCache +- **Fabric**: Isaac Lab's FrameView via Fabric GPU arrays +- **Newton**: Isaac Lab's Newton FrameView via Warp site kernels +- **PhysX**: PhysX RigidBodyView via PhysX tensor API (reference) Usage: - # Basic benchmark + # All backends ./isaaclab.sh -p scripts/benchmarks/benchmark_view_comparison.py --num_envs 1024 --device cuda:0 --headless - # With profiling enabled (for snakeviz visualization) - ./isaaclab.sh -p scripts/benchmarks/benchmark_view_comparison.py --num_envs 1024 --profile --headless + # Select specific backends + ./isaaclab.sh -p scripts/benchmarks/benchmark_view_comparison.py --backends usd fabric newton --headless - # Then visualize with snakeviz: - snakeviz profile_results/xform_view_benchmark.prof - snakeviz profile_results/physx_view_benchmark.prof + # With profiling + ./isaaclab.sh -p scripts/benchmarks/benchmark_view_comparison.py --num_envs 1024 --profile --headless """ from __future__ import annotations -"""Launch Isaac Sim Simulator first.""" - import argparse from isaaclab.app import AppLauncher -# parse the arguments -args_cli = argparse.Namespace() - -parser = argparse.ArgumentParser(description="Benchmark XformPrimView vs PhysX RigidBodyView performance.") +parser = argparse.ArgumentParser(description="Benchmark FrameView backends and PhysX RigidBodyView.") parser.add_argument("--num_envs", type=int, default=1000, help="Number of environments to simulate.") parser.add_argument("--num_iterations", type=int, default=50, help="Number of iterations for each test.") +parser.add_argument( + "--backends", + nargs="+", + default=["usd", "fabric", "newton", "physx"], + choices=["usd", "fabric", "newton", "physx"], + help="Backends to benchmark. Default: all four.", +) parser.add_argument( "--profile", action="store_true", help="Enable profiling with cProfile. Results saved as .prof files for snakeviz visualization.", ) parser.add_argument( - "--profile-dir", + "--profile_dir", type=str, default="./profile_results", help="Directory to save profile results. Default: ./profile_results", @@ -59,7 +55,6 @@ AppLauncher.add_app_launcher_args(parser) args_cli = parser.parse_args() -# launch omniverse app app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app @@ -69,40 +64,40 @@ import time import torch +import warp as wp + +from pxr import Gf import isaaclab.sim as sim_utils -from isaaclab.sim.views import XformPrimView +from isaaclab.sim.views import FrameView + +try: + from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg + from isaaclab_newton.sim.views import NewtonSiteFrameView + + HAS_NEWTON = True +except ImportError: + HAS_NEWTON = False + + +# ------------------------------------------------------------------ +# Benchmark functions +# ------------------------------------------------------------------ @torch.no_grad() -def benchmark_view(view_type: str, num_iterations: int) -> tuple[dict[str, float], dict[str, torch.Tensor]]: - """Benchmark the specified view class. - - Args: - view_type: Type of view to benchmark ("xform", "xform_fabric", or "physx"). - num_iterations: Number of iterations to run. - - Returns: - A tuple of (timing_results, computed_results) where: - - timing_results: Dictionary containing timing results for various operations - - computed_results: Dictionary containing the computed values for validation - """ +def benchmark_usd_or_fabric(view_type: str, num_iterations: int) -> dict[str, float]: + """Benchmark USD or Fabric FrameView.""" timing_results = {} - computed_results = {} - # Setup scene print(" Setting up scene") - # Clear stage sim_utils.create_new_stage() - # Create simulation context start_time = time.perf_counter() - sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device, use_fabric=(view_type == "xform_fabric")) + sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device, use_fabric=(view_type == "fabric")) sim = sim_utils.SimulationContext(sim_cfg) stage = sim_utils.get_current_stage() + print(f" SimulationContext: {time.perf_counter() - start_time:.4f}s") - print(f" Time taken to create simulation context: {time.perf_counter() - start_time:.4f} seconds") - - # create a rigid object object_cfg = sim_utils.ConeCfg( radius=0.15, height=0.5, @@ -111,222 +106,223 @@ def benchmark_view(view_type: str, num_iterations: int) -> tuple[dict[str, float collision_props=sim_utils.CollisionPropertiesCfg(), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 1.0, 0.0)), ) - # Create prims for i in range(args_cli.num_envs): sim_utils.create_prim(f"/World/Env_{i}", "Xform", stage=stage, translation=(i * 2.0, 0.0, 0.0)) object_cfg.func(f"/World/Env_{i}/Object", object_cfg, translation=(0.0, 0.0, 1.0)) + prim = stage.DefinePrim(f"/World/Env_{i}/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)) - # Play simulation sim.reset() - # Pattern to match all prims - pattern = "/World/Env_.*/Object" if view_type == "xform" else "/World/Env_*/Object" - print(f" Pattern: {pattern}") + pattern = "/World/Env_.*/Object/Sensor" - # Create view based on type start_time = time.perf_counter() - if view_type == "xform": - view = XformPrimView(pattern, device=args_cli.device, validate_xform_ops=False) - num_prims = view.count - view_name = "XformPrimView (USD)" - elif view_type == "xform_fabric": - if "cuda" not in args_cli.device: - raise ValueError("Fabric backend requires CUDA. Please use --device cuda:0 for this benchmark.") - view = XformPrimView(pattern, device=args_cli.device, validate_xform_ops=False) - num_prims = view.count - view_name = "XformPrimView (Fabric)" - else: # physx - physics_sim_view = sim.physics_manager.get_physics_sim_view() - view = physics_sim_view.create_rigid_body_view(pattern) - num_prims = view.count - view_name = "PhysX RigidBodyView" + if view_type == "fabric" and "cuda" not in args_cli.device: + raise ValueError("Fabric backend requires CUDA.") + view = FrameView(pattern, device=args_cli.device, validate_xform_ops=False) + num_prims = view.count timing_results["init"] = time.perf_counter() - start_time - # prepare indices for benchmarking - all_indices = torch.arange(num_prims, device=args_cli.device) - - print(f" {view_name} managing {num_prims} prims") - - # Fabric is write-first: initialize it to match USD before benchmarking reads. - if view_type == "xform_fabric" and num_prims > 0: - init_positions = torch.zeros((num_prims, 3), dtype=torch.float32, device=args_cli.device) - init_positions[:, 0] = 2.0 * torch.arange(num_prims, device=args_cli.device, dtype=torch.float32) - init_positions[:, 2] = 1.0 - init_orientations = torch.tensor( - [[1.0, 0.0, 0.0, 0.0]] * num_prims, dtype=torch.float32, device=args_cli.device + + print(f" FrameView ({view_type.upper()}) managing {num_prims} prims") + + positions, orientations = view.get_world_poses() + + _run_pose_benchmarks(view, num_prims, num_iterations, timing_results, positions, orientations) + + sim.clear_instance() + return timing_results + + +@torch.no_grad() +def benchmark_newton(num_iterations: int) -> dict[str, float]: + """Benchmark Newton FrameView.""" + from isaaclab.assets import RigidObjectCfg + from isaaclab.scene import InteractiveScene, InteractiveSceneCfg + from isaaclab.sim import SimulationCfg, build_simulation_context + from isaaclab.utils import configclass + + timing_results = {} + + @configclass + class _SceneCfg(InteractiveSceneCfg): + cube: RigidObjectCfg = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube", + spawn=sim_utils.CuboidCfg( + size=(0.2, 0.2, 0.2), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), ) - view.set_world_poses(init_positions, init_orientations) - # Benchmark get_world_poses + print(" Setting up Newton scene") + newton_cfg = SimulationCfg(physics=NewtonCfg(solver_cfg=MJWarpSolverCfg()), device=args_cli.device) start_time = time.perf_counter() - for _ in range(num_iterations): - if view_type in ("xform", "xform_fabric"): - positions, orientations = view.get_world_poses() - else: # physx - transforms = view.get_transforms() - positions = transforms[:, :3] - orientations = transforms[:, 3:7] - timing_results["get_world_poses"] = (time.perf_counter() - start_time) / num_iterations + ctx = build_simulation_context(device=args_cli.device, sim_cfg=newton_cfg, 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)) - # Store initial world poses - computed_results["initial_world_positions"] = positions.clone() - computed_results["initial_world_orientations"] = orientations.clone() + 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") + 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)) + + sim.reset() + print(f" Newton scene setup: {time.perf_counter() - start_time:.4f}s") - # Benchmark set_world_poses - new_positions = positions.clone() - new_positions[:, 2] += 0.5 start_time = time.perf_counter() - for _ in range(num_iterations): - if view_type in ("xform", "xform_fabric"): - view.set_world_poses(new_positions, orientations) - else: # physx - new_transforms = torch.cat([new_positions, orientations], dim=-1) - view.set_transforms(new_transforms, indices=all_indices) - timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations + view = NewtonSiteFrameView("/World/envs/env_.*/Cube/Sensor", device=args_cli.device) + num_prims = view.count + timing_results["init"] = time.perf_counter() - start_time - # Get world poses after setting to verify - if view_type in ("xform", "xform_fabric"): - positions_after_set, orientations_after_set = view.get_world_poses() - else: # physx - transforms_after = view.get_transforms() - positions_after_set = transforms_after[:, :3] - orientations_after_set = transforms_after[:, 3:7] - computed_results["world_positions_after_set"] = positions_after_set.clone() - computed_results["world_orientations_after_set"] = orientations_after_set.clone() - - # close simulation - sim.clear_instance() + print(f" Newton FrameView managing {num_prims} prims") - return timing_results, computed_results + positions, orientations = view.get_world_poses() + _run_pose_benchmarks(view, num_prims, num_iterations, timing_results, positions, orientations) -def compare_results( - results_dict: dict[str, dict[str, torch.Tensor]], tolerance: float = 1e-4 -) -> dict[str, dict[str, dict[str, float]]]: - """Compare computed results across implementations. + ctx.__exit__(None, None, None) + return timing_results - Args: - results_dict: Dictionary mapping implementation names to their computed values. - tolerance: Tolerance for numerical comparison. - Returns: - Nested dictionary: {comparison_pair: {metric: {stats}}} - """ - comparison_stats = {} - impl_names = list(results_dict.keys()) +@torch.no_grad() +def benchmark_physx(num_iterations: int) -> dict[str, float]: + """Benchmark PhysX RigidBodyView.""" + timing_results = {} - # Compare each pair of implementations - for i, impl1 in enumerate(impl_names): - for impl2 in impl_names[i + 1 :]: - pair_key = f"{impl1}_vs_{impl2}" - comparison_stats[pair_key] = {} + print(" Setting up scene") + sim_utils.create_new_stage() + start_time = time.perf_counter() + sim_cfg = sim_utils.SimulationCfg(dt=0.01, device=args_cli.device, use_fabric=False) + sim = sim_utils.SimulationContext(sim_cfg) + stage = sim_utils.get_current_stage() + print(f" SimulationContext: {time.perf_counter() - start_time:.4f}s") - computed1 = results_dict[impl1] - computed2 = results_dict[impl2] + object_cfg = sim_utils.ConeCfg( + radius=0.15, + height=0.5, + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.0, 1.0, 0.0)), + ) + for i in range(args_cli.num_envs): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", stage=stage, translation=(i * 2.0, 0.0, 0.0)) + object_cfg.func(f"/World/Env_{i}/Object", object_cfg, translation=(0.0, 0.0, 1.0)) - for key in computed1.keys(): - if key not in computed2: - continue + sim.reset() - val1 = computed1[key] - val2 = computed2[key] + pattern = "/World/Env_*/Object" + start_time = time.perf_counter() + physics_sim_view = sim.physics_manager.get_physics_sim_view() + view = physics_sim_view.create_rigid_body_view(pattern) + num_prims = view.count + timing_results["init"] = time.perf_counter() - start_time - # Skip zero tensors (not applicable tests) - if torch.all(val1 == 0) or torch.all(val2 == 0): - continue + print(f" PhysX RigidBodyView managing {num_prims} prims") - # Compute differences - diff = torch.abs(val1 - val2) - max_diff = torch.max(diff).item() - mean_diff = torch.mean(diff).item() + all_indices = wp.from_torch(torch.arange(num_prims, dtype=torch.int32, device=args_cli.device)) - # Check if within tolerance - all_close = torch.allclose(val1, val2, atol=tolerance, rtol=0) + transforms = view.get_transforms() + transforms_t = wp.to_torch(transforms) if isinstance(transforms, wp.array) else transforms + positions_t = transforms_t[:, :3] + orientations_t = transforms_t[:, 3:7] - comparison_stats[pair_key][key] = { - "max_diff": max_diff, - "mean_diff": mean_diff, - "all_close": all_close, - } + start_time = time.perf_counter() + for _ in range(num_iterations): + transforms = view.get_transforms() + timing_results["get_world_poses"] = (time.perf_counter() - start_time) / num_iterations - return comparison_stats + new_positions = positions_t.clone() + new_positions[:, 2] += 0.5 + expected_positions = new_positions.clone() + new_transforms = wp.from_torch(torch.cat([new_positions, orientations_t], dim=-1).contiguous()) + start_time = time.perf_counter() + for _ in range(num_iterations): + view.set_transforms(new_transforms, indices=all_indices) + timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations + transforms_after = view.get_transforms() + ta = wp.to_torch(transforms_after) if isinstance(transforms_after, wp.array) else transforms_after + pos_ok = torch.allclose(ta[:, :3], expected_positions, atol=1e-4, rtol=0) + quat_ok = torch.allclose(ta[:, 3:7], orientations_t, atol=1e-4, rtol=0) + if pos_ok and quat_ok: + print(" Round-trip verification: PASS") + else: + pos_diff = (ta[:, :3] - expected_positions).abs().max().item() + quat_diff = (ta[:, 3:7] - orientations_t).abs().max().item() + print(f" Round-trip verification: FAIL (pos max_diff={pos_diff:.6e}, quat max_diff={quat_diff:.6e})") -def print_comparison_results(comparison_stats: dict[str, dict[str, dict[str, float]]], tolerance: float): - """Print comparison results. + sim.clear_instance() + return timing_results + + +def _run_pose_benchmarks( + view, + num_prims: int, + num_iterations: int, + timing_results: dict, + positions: wp.array, + orientations: wp.array, +): + """Shared benchmark loop for get/set world poses on any FrameView.""" + start_time = time.perf_counter() + for _ in range(num_iterations): + view.get_world_poses() + timing_results["get_world_poses"] = (time.perf_counter() - start_time) / num_iterations - Args: - comparison_stats: Nested dictionary containing comparison statistics. - tolerance: Tolerance used for comparison. - """ - for pair_key, pair_stats in comparison_stats.items(): - if not pair_stats: # Skip if no comparable results - continue + 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() - # Format the pair key for display - impl1, impl2 = pair_key.split("_vs_") - display_impl1 = impl1.replace("_", " ").title() - display_impl2 = impl2.replace("_", " ").title() - comparison_title = f"{display_impl1} vs {display_impl2}" - - # Check if all results match - all_match = all(stats["all_close"] for stats in pair_stats.values()) - - if all_match: - # Compact output when everything matches - print("\n" + "=" * 100) - print(f"RESULT COMPARISON: {comparison_title}") - print("=" * 100) - print(f"✓ All computed values match within tolerance ({tolerance})") - print("=" * 100) - else: - # Detailed output when there are mismatches - print("\n" + "=" * 100) - print(f"RESULT COMPARISON: {comparison_title}") - print("=" * 100) - print(f"{'Computed Value':<40} {'Max Diff':<15} {'Mean Diff':<15} {'Match':<10}") - print("-" * 100) - - for key, stats in pair_stats.items(): - # Format the key for display - display_key = key.replace("_", " ").title() - match_str = "✓ Yes" if stats["all_close"] else "✗ No" - - print(f"{display_key:<40} {stats['max_diff']:<15.6e} {stats['mean_diff']:<15.6e} {match_str:<10}") - - print("=" * 100) - print(f"\n✗ Some results differ beyond tolerance ({tolerance})") - print(f" This may indicate implementation differences between {display_impl1} and {display_impl2}") + start_time = time.perf_counter() + for _ in range(num_iterations): + view.set_world_poses(new_positions, orientations) + 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) + + 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) + if pos_ok and quat_ok: + print(" Round-trip verification: PASS") + else: + pos_diff = (ret_pos_t - expected_positions).abs().max().item() + quat_diff = (ret_quat_t - ori_t).abs().max().item() + print(f" Round-trip verification: FAIL (pos max_diff={pos_diff:.6e}, quat max_diff={quat_diff:.6e})") - print() + +# ------------------------------------------------------------------ +# Reporting +# ------------------------------------------------------------------ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num_iterations: int): - """Print benchmark results in a formatted table. - - Args: - results_dict: Dictionary mapping implementation names to their timing results. - num_prims: Number of prims tested. - num_iterations: Number of iterations run. - """ - print("\n" + "=" * 100) + """Print benchmark results in a formatted table.""" + print("\n" + "=" * 120) print(f"BENCHMARK RESULTS: {num_prims} prims, {num_iterations} iterations") - print("=" * 100) + print("=" * 120) impl_names = list(results_dict.keys()) - # Format names for display - display_names = [name.replace("_", " ").title() for name in impl_names] - - # Calculate column width - col_width = 20 + display_names = {n: n.replace("_", " ").title() for n in impl_names} + col_width = 22 - # Print header - header = f"{'Operation':<30}" - for display_name in display_names: - header += f" {display_name + ' (ms)':<{col_width}}" + header = f"{'Operation':<25}" + for name in impl_names: + header += f" {display_names[name] + ' (ms)':>{col_width}}" print(header) - print("-" * 100) + print("-" * 120) - # Print each operation operations = [ ("Initialization", "init"), ("Get World Poses", "get_world_poses"), @@ -334,168 +330,117 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num ] for op_name, op_key in operations: - row = f"{op_name:<30}" - for impl_name in impl_names: - impl_time = results_dict[impl_name].get(op_key, 0) * 1000 # Convert to ms - row += f" {impl_time:>{col_width - 1}.4f}" + row = f"{op_name:<25}" + for name in impl_names: + val = results_dict[name].get(op_key, 0) * 1000 + row += f" {val:>{col_width}.4f}" print(row) - print("=" * 100) - - # Calculate and print total time (excluding N/A operations) - total_row = f"{'Total Time':<30}" - for impl_name in impl_names: - if impl_name == "physx_view": - # Exclude local pose operations for PhysX - total_time = ( - results_dict[impl_name].get("init", 0) * 1000 - + results_dict[impl_name].get("get_world_poses", 0) * 1000 - + results_dict[impl_name].get("set_world_poses", 0) * 1000 - ) - else: - total_time = sum(results_dict[impl_name].values()) * 1000 - total_row += f" {total_time:>{col_width - 1}.4f}" - print(f"\n{total_row}") - - # Calculate speedups relative to XformPrimView (USD baseline) - if "xform_view" in impl_names: - print("\n" + "=" * 100) - print("SPEEDUP vs XformPrimView (USD)") - print("=" * 100) - print(f"{'Operation':<30}", end="") - for impl_name, display_name in zip(impl_names, display_names): - if impl_name != "xform_view": - print(f" {display_name + ' Speedup':<{col_width}}", end="") - print() - print("-" * 100) - - xform_results = results_dict["xform_view"] + print("=" * 120) + + total_row = f"{'Total':<25}" + for name in impl_names: + total = sum(results_dict[name].values()) * 1000 + total_row += f" {total:>{col_width}.4f}" + print(total_row) + + baseline = "usd" + if baseline in results_dict and len(impl_names) > 1: + print("\n" + "=" * 120) + print(f"SPEEDUP vs {display_names[baseline]}") + print("=" * 120) + header = f"{'Operation':<25}" + for name in impl_names: + if name != baseline: + header += f" {display_names[name]:>{col_width}}" + print(header) + print("-" * 120) + + base = results_dict[baseline] for op_name, op_key in operations: - print(f"{op_name:<30}", end="") - xform_time = xform_results.get(op_key, 0) - for impl_name, display_name in zip(impl_names, display_names): - if impl_name != "xform_view": - impl_time = results_dict[impl_name].get(op_key, 0) - if xform_time > 0 and impl_time > 0: - speedup = impl_time / xform_time - print(f" {speedup:>{col_width - 1}.2f}x", end="") + row = f"{op_name:<25}" + base_t = base.get(op_key, 0) + for name in impl_names: + if name != baseline: + impl_t = results_dict[name].get(op_key, 0) + if base_t > 0 and impl_t > 0: + row += f" {base_t / impl_t:>{col_width}.2f}x" else: - print(f" {'N/A':>{col_width}}", end="") - print() + row += f" {'N/A':>{col_width}}" + print(row) + print("=" * 120) - # Overall speedup (only world pose operations) - print("=" * 100) - print(f"{'Overall Speedup (World Ops)':<30}", end="") - total_xform = ( - xform_results.get("init", 0) - + xform_results.get("get_world_poses", 0) - + xform_results.get("set_world_poses", 0) - ) - for impl_name, display_name in zip(impl_names, display_names): - if impl_name != "xform_view": - total_impl = ( - results_dict[impl_name].get("init", 0) - + results_dict[impl_name].get("get_world_poses", 0) - + results_dict[impl_name].get("set_world_poses", 0) - ) - if total_xform > 0 and total_impl > 0: - overall_speedup = total_impl / total_xform - print(f" {overall_speedup:>{col_width - 1}.2f}x", end="") - else: - print(f" {'N/A':>{col_width}}", end="") - print() - - print("\n" + "=" * 100) print("\nNotes:") print(" - Times are averaged over all iterations") - print(" - Speedup = (Implementation time) / (XformPrimView USD time)") - print(" - Speedup > 1.0 means USD XformPrimView is faster") - print(" - Speedup < 1.0 means the implementation is faster than USD") - print(" - PhysX View requires rigid body physics components") - print(" - XformPrimView works with any Xform prim (physics or non-physics)") - print(" - PhysX View does not support local pose operations directly") + print(" - Speedup > 1.0 means faster than USD baseline") + print(" - PhysX RigidBodyView requires rigid body physics; FrameView works with any Xformable prim") print() +# ------------------------------------------------------------------ +# Main +# ------------------------------------------------------------------ + + def main(): - """Main benchmark function.""" - print("=" * 100) - print("View Comparison Benchmark - XformPrimView vs PhysX RigidBodyView") - print("=" * 100) - print("Configuration:") - print(f" Number of environments: {args_cli.num_envs}") - print(f" Iterations per test: {args_cli.num_iterations}") - print(f" Device: {args_cli.device}") - print(f" Profiling: {'Enabled' if args_cli.profile else 'Disabled'}") - if args_cli.profile: - print(f" Profile directory: {args_cli.profile_dir}") + print("=" * 120) + print("FrameView Benchmark: USD vs Fabric vs Newton vs PhysX") + print("=" * 120) + print(f" Environments: {args_cli.num_envs}") + print(f" Iterations: {args_cli.num_iterations}") + print(f" Device: {args_cli.device}") + print(f" Backends: {', '.join(args_cli.backends)}") print() - # Create profile directory if profiling is enabled if args_cli.profile: import os os.makedirs(args_cli.profile_dir, exist_ok=True) - # Dictionary to store all results - all_timing_results = {} - all_computed_results = {} + all_timing = {} profile_files = {} - # Implementations to benchmark - implementations = [ - ("xform_view", "XformPrimView (USD)", "xform"), - ("xform_fabric_view", "XformPrimView (Fabric)", "xform_fabric"), - ("physx_view", "PhysX RigidBodyView", "physx"), - ] + dispatch = { + "usd": ("usd", "FrameView (USD)", lambda n: benchmark_usd_or_fabric("usd", n)), + "fabric": ("fabric", "FrameView (Fabric)", lambda n: benchmark_usd_or_fabric("fabric", n)), + "newton": ("newton", "FrameView (Newton)", lambda n: benchmark_newton(n)), + "physx": ("physx", "PhysX RigidBodyView", lambda n: benchmark_physx(n)), + } - # Benchmark each implementation - for impl_key, impl_name, view_type in implementations: - print(f"Benchmarking {impl_name}...") + for backend in args_cli.backends: + if backend == "newton" and not HAS_NEWTON: + print(f"Skipping {backend}: isaaclab_newton not installed") + continue + + key, display_name, bench_fn = dispatch[backend] + print(f"Benchmarking {display_name}...") if args_cli.profile: profiler = cProfile.Profile() profiler.enable() - timing, computed = benchmark_view(view_type=view_type, num_iterations=args_cli.num_iterations) + timing = bench_fn(args_cli.num_iterations) if args_cli.profile: profiler.disable() - profile_file = f"{args_cli.profile_dir}/{impl_key}_benchmark.prof" - profiler.dump_stats(profile_file) - profile_files[impl_key] = profile_file - print(f" Profile saved to: {profile_file}") - - all_timing_results[impl_key] = timing - all_computed_results[impl_key] = computed + pf = f"{args_cli.profile_dir}/{key}_benchmark.prof" + profiler.dump_stats(pf) + profile_files[key] = pf + print(f" Profile saved to: {pf}") - print(" Done!") - print() + all_timing[key] = timing + print(" Done!\n") - # Print timing results - print_results(all_timing_results, args_cli.num_envs, args_cli.num_iterations) + print_results(all_timing, args_cli.num_envs, args_cli.num_iterations) - # Compare computed results - print("\nComparing computed results across implementations...") - comparison_stats = compare_results(all_computed_results, tolerance=1e-4) - print_comparison_results(comparison_stats, tolerance=1e-4) - - # Print profiling instructions if enabled if args_cli.profile: print("\n" + "=" * 100) print("PROFILING RESULTS") print("=" * 100) - print("Profile files have been saved. To visualize with snakeviz, run:") - for impl_key, profile_file in profile_files.items(): - impl_display = impl_key.replace("_", " ").title() - print(f" # {impl_display}") - print(f" snakeviz {profile_file}") - print("\nAlternatively, use pstats to analyze in terminal:") - print(" python -m pstats ") - print("=" * 100) + for key, pf in profile_files.items(): + print(f" snakeviz {pf}") print() - # Clean up sim_utils.SimulationContext.clear_instance() diff --git a/scripts/benchmarks/benchmark_xform_prim_view.py b/scripts/benchmarks/benchmark_xform_prim_view.py index e76796e20271..b682c03f71fc 100644 --- a/scripts/benchmarks/benchmark_xform_prim_view.py +++ b/scripts/benchmarks/benchmark_xform_prim_view.py @@ -3,59 +3,35 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Benchmark script comparing XformPrimView implementations across different APIs. +"""Benchmark script comparing FrameView implementations across backends. -This script tests the performance of batched transform operations using: -- Isaac Lab's XformPrimView implementation with USD backend -- Isaac Lab's XformPrimView implementation with Fabric backend -- Isaac Sim's XformPrimView implementation (legacy) -- Isaac Sim Experimental's XformPrim implementation (latest) +Compares batched transform operation performance across: +- Isaac Lab FrameView (USD backend) -- baseline +- Isaac Lab FrameView (Fabric backend) +- Isaac Lab FrameView (Newton backend) Usage: - # Basic benchmark (all APIs) ./isaaclab.sh -p scripts/benchmarks/benchmark_xform_prim_view.py --num_envs 1024 --device cuda:0 --headless - # With profiling enabled (for snakeviz visualization) + # With profiling ./isaaclab.sh -p scripts/benchmarks/benchmark_xform_prim_view.py --num_envs 1024 --profile --headless - - # Then visualize with snakeviz: - snakeviz profile_results/isaaclab_usd_benchmark.prof - snakeviz profile_results/isaaclab_fabric_benchmark.prof - snakeviz profile_results/isaacsim_benchmark.prof - snakeviz profile_results/isaacsim_exp_benchmark.prof """ from __future__ import annotations -"""Launch Isaac Sim Simulator first.""" - import argparse from isaaclab.app import AppLauncher -# parse the arguments -args_cli = argparse.Namespace() - -parser = argparse.ArgumentParser(description="This script can help you benchmark the performance of XformPrimView.") - +parser = argparse.ArgumentParser(description="Benchmark FrameView performance across backends.") parser.add_argument("--num_envs", type=int, default=100, help="Number of environments to simulate.") parser.add_argument("--num_iterations", type=int, default=50, help="Number of iterations for each test.") -parser.add_argument( - "--profile", - action="store_true", - help="Enable profiling with cProfile. Results saved as .prof files for snakeviz visualization.", -) -parser.add_argument( - "--profile-dir", - type=str, - default="./profile_results", - help="Directory to save profile results. Default: ./profile_results", -) +parser.add_argument("--profile", action="store_true", help="Enable cProfile profiling.") +parser.add_argument("--profile_dir", type=str, default="./profile_results", help="Directory for .prof files.") AppLauncher.add_app_launcher_args(parser) args_cli = parser.parse_args() -# launch omniverse app app_launcher = AppLauncher(args_cli) simulation_app = app_launcher.app @@ -66,402 +42,231 @@ from typing import Literal import torch +import warp as wp +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.sim.views import NewtonSiteFrameView +from isaaclab_physx.sim.views import FabricFrameView -from isaacsim.core.prims import XFormPrim as IsaacSimXformPrimView -from isaacsim.core.utils.extensions import enable_extension - -# compare against latest Isaac Sim implementation -enable_extension("isaacsim.core.experimental.prims") -from isaacsim.core.experimental.prims import XformPrim as IsaacSimExperimentalXformPrimView +from pxr import Gf import isaaclab.sim as sim_utils -from isaaclab.sim.views import XformPrimView as IsaacLabXformPrimView +from isaaclab.assets import RigidObjectCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.sim import SimulationCfg, build_simulation_context +from isaaclab.sim.views import UsdFrameView +from isaaclab.utils import configclass + + +@configclass +class _NewtonSceneCfg(InteractiveSceneCfg): + cube: RigidObjectCfg = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Object", + spawn=sim_utils.CuboidCfg( + size=(0.2, 0.2, 0.2), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), + ) + + +# ------------------------------------------------------------------ +# Benchmark +# ------------------------------------------------------------------ @torch.no_grad() -def benchmark_xform_prim_view( # noqa: C901 - api: Literal["isaaclab-usd", "isaaclab-fabric", "isaacsim-usd", "isaacsim-fabric", "isaacsim-exp"], +def benchmark_frame_view( # noqa: C901 + api: Literal["isaaclab-usd", "isaaclab-fabric", "isaaclab-newton-site"], num_iterations: int, ) -> tuple[dict[str, float], dict[str, torch.Tensor]]: - """Benchmark the Xform view class from Isaac Lab, Isaac Sim, or Isaac Sim Experimental. - - Args: - api: Which API to benchmark: - - "isaaclab-usd": Isaac Lab XformPrimView with USD backend - - "isaaclab-fabric": Isaac Lab XformPrimView with Fabric backend - - "isaacsim-usd": Isaac Sim legacy XformPrimView with USD (usd=True) - - "isaacsim-fabric": Isaac Sim legacy XformPrimView with Fabric (usd=False) - - "isaacsim-exp": Isaac Sim Experimental XformPrim - num_iterations: Number of iterations to run. - - Returns: - A tuple of (timing_results, computed_results) where: - - timing_results: Dictionary containing timing results for various operations - - computed_results: Dictionary containing the computed values for validation - """ - timing_results = {} - computed_results = {} - - # Setup scene + """Benchmark get/set world/local poses for the given FrameView backend.""" + timing_results: dict[str, float] = {} + computed_results: dict[str, torch.Tensor] = {} + device = args_cli.device + num_envs = args_cli.num_envs + + # -- Scene setup (backend-specific) -------------------------------- + print(" Setting up scene") - # Clear stage - sim_utils.create_new_stage() - # Create simulation context - start_time = time.perf_counter() - sim_cfg = sim_utils.SimulationCfg( - dt=0.01, - device=args_cli.device, - use_fabric=api in ("isaaclab-fabric", "isaacsim-fabric"), - ) - sim = sim_utils.SimulationContext(sim_cfg) - stage = sim_utils.get_current_stage() - - print(f" Time taken to create simulation context: {time.perf_counter() - start_time} seconds") - - # Create prims - prim_paths = [] - for i in range(args_cli.num_envs): - sim_utils.create_prim(f"/World/Env_{i}", "Xform", stage=stage, translation=(i * 2.0, 0.0, 1.0)) - sim_utils.create_prim(f"/World/Env_{i}/Object", "Xform", stage=stage, translation=(0.0, 0.0, 0.0)) - prim_paths.append(f"/World/Env_{i}/Object") - # Play simulation - sim.reset() - - # Pattern to match all prims - pattern = "/World/Env_.*/Object" - print(f" Pattern: {pattern}") - - # Create view - start_time = time.perf_counter() - if api == "isaaclab-usd" or api == "isaaclab-fabric": - xform_view = IsaacLabXformPrimView(pattern, device=args_cli.device, validate_xform_ops=False) - elif api == "isaacsim-usd": - xform_view = IsaacSimXformPrimView(pattern, reset_xform_properties=False, usd=True) - elif api == "isaacsim-fabric": - xform_view = IsaacSimXformPrimView(pattern, reset_xform_properties=False, usd=False) - elif api == "isaacsim-exp": - xform_view = IsaacSimExperimentalXformPrimView(pattern) + cleanup = None + + if api == "isaaclab-newton-site": + newton_cfg = SimulationCfg(device=device, physics=NewtonCfg(solver_cfg=MJWarpSolverCfg())) + ctx = build_simulation_context(device=device, sim_cfg=newton_cfg, add_ground_plane=True) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + InteractiveScene(_NewtonSceneCfg(num_envs=num_envs, env_spacing=2.0)) + + stage = sim_utils.get_current_stage() + for i in range(num_envs): + prim = stage.DefinePrim(f"/World/envs/env_{i}/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)) + + sim.reset() + + start_time = time.perf_counter() + xform_view = NewtonSiteFrameView("/World/envs/env_.*/Object/Sensor", device=device) + timing_results["init"] = time.perf_counter() - start_time + cleanup = lambda: ctx.__exit__(None, None, None) # noqa: E731 + else: - raise ValueError(f"Invalid API: {api}") - timing_results["init"] = time.perf_counter() - start_time - - if api in ("isaaclab-usd", "isaaclab-fabric", "isaacsim-usd", "isaacsim-fabric"): - num_prims = xform_view.count - elif api == "isaacsim-exp": - num_prims = len(xform_view.prims) - print(f" XformView managing {num_prims} prims") - - # Benchmark get_world_poses - # Warmup call to initialize Fabric (if needed) - excluded from timing - positions, orientations = xform_view.get_world_poses() - - # Now time the actual iterations (steady-state performance) - start_time = time.perf_counter() - for _ in range(num_iterations): - positions, orientations = xform_view.get_world_poses() - - # Ensure tensors are torch tensors (do this AFTER timing) - if not isinstance(positions, torch.Tensor): - positions = torch.tensor(positions, dtype=torch.float32) - if not isinstance(orientations, torch.Tensor): - orientations = torch.tensor(orientations, dtype=torch.float32) - - timing_results["get_world_poses"] = (time.perf_counter() - start_time) / num_iterations - - # Store initial world poses - computed_results["initial_world_positions"] = positions.clone() - computed_results["initial_world_orientations"] = orientations.clone() - - # Benchmark set_world_poses - new_positions = positions.clone() - new_positions[:, 2] += 0.1 - start_time = time.perf_counter() - for _ in range(num_iterations): - if api in ("isaaclab-usd", "isaaclab-fabric", "isaacsim-usd", "isaacsim-fabric"): + sim_utils.create_new_stage() + start_time = time.perf_counter() + use_fabric = api == "isaaclab-fabric" + sim = sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=use_fabric)) + stage = sim_utils.get_current_stage() + + for i in range(num_envs): + sim_utils.create_prim(f"/World/Env_{i}", "Xform", stage=stage, translation=(i * 2.0, 0.0, 1.0)) + sim_utils.create_prim(f"/World/Env_{i}/Object", "Xform", stage=stage, translation=(0.0, 0.0, 0.0)) + + sim.reset() + + pattern = "/World/Env_.*/Object" + start_time = time.perf_counter() + ViewClass = FabricFrameView if use_fabric else UsdFrameView + xform_view = ViewClass(pattern, device=device, validate_xform_ops=False) + timing_results["init"] = time.perf_counter() - start_time + cleanup = lambda: sim.clear_instance() # noqa: E731 + + num_prims = xform_view.count + print(f" {api} managing {num_prims} prims") + + is_newton = api == "isaaclab-newton-site" + + def to_torch(a): + return wp.to_torch(a) if isinstance(a, wp.array) else a + + try: + # -- Warmup -------------------------------------------------------- + xform_view.get_world_poses() + + # -- get_world_poses ----------------------------------------------- + if is_newton: + torch.cuda.synchronize() + start_time = time.perf_counter() + for _ in range(num_iterations): + positions, orientations = xform_view.get_world_poses() + if is_newton: + torch.cuda.synchronize() + timing_results["get_world_poses"] = (time.perf_counter() - start_time) / num_iterations + + positions_t = to_torch(positions) + orientations_t = to_torch(orientations) + computed_results["initial_world_positions"] = positions_t.clone() + computed_results["initial_world_orientations"] = orientations_t.clone() + + # -- set_world_poses ----------------------------------------------- + if is_newton: + new_positions = wp.clone(positions) + wp.to_torch(new_positions)[:, 2] += 0.1 + else: + new_positions = positions_t.clone() + new_positions[:, 2] += 0.1 + + if is_newton: + torch.cuda.synchronize() + start_time = time.perf_counter() + for _ in range(num_iterations): xform_view.set_world_poses(new_positions, orientations) - elif api == "isaacsim-exp": - xform_view.set_world_poses(new_positions.cpu().numpy(), orientations.cpu().numpy()) - timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations - - # Get world poses after setting to verify - positions_after_set, orientations_after_set = xform_view.get_world_poses() - if not isinstance(positions_after_set, torch.Tensor): - positions_after_set = torch.tensor(positions_after_set, dtype=torch.float32) - if not isinstance(orientations_after_set, torch.Tensor): - orientations_after_set = torch.tensor(orientations_after_set, dtype=torch.float32) - computed_results["world_positions_after_set"] = positions_after_set.clone() - computed_results["world_orientations_after_set"] = orientations_after_set.clone() - - # Benchmark get_local_poses - # Warmup call (though local poses use USD, so minimal overhead) - translations, orientations_local = xform_view.get_local_poses() - - # Now time the actual iterations - start_time = time.perf_counter() - for _ in range(num_iterations): - translations, orientations_local = xform_view.get_local_poses() - # Ensure tensors are torch tensors (do this AFTER timing) - if not isinstance(translations, torch.Tensor): - translations = torch.tensor(translations, dtype=torch.float32, device=args_cli.device) - if not isinstance(orientations_local, torch.Tensor): - orientations_local = torch.tensor(orientations_local, dtype=torch.float32, device=args_cli.device) - - timing_results["get_local_poses"] = (time.perf_counter() - start_time) / num_iterations - - # Store initial local poses - computed_results["initial_local_translations"] = translations.clone() - computed_results["initial_local_orientations"] = orientations_local.clone() - - # Benchmark set_local_poses - new_translations = translations.clone() - new_translations[:, 2] += 0.1 - start_time = time.perf_counter() - for _ in range(num_iterations): - if api in ("isaaclab-usd", "isaaclab-fabric", "isaacsim-usd", "isaacsim-fabric"): + if is_newton: + torch.cuda.synchronize() + timing_results["set_world_poses"] = (time.perf_counter() - start_time) / num_iterations + + pa, oa = xform_view.get_world_poses() + computed_results["world_positions_after_set"] = to_torch(pa).clone() + computed_results["world_orientations_after_set"] = to_torch(oa).clone() + + # -- get_local_poses ----------------------------------------------- + if is_newton: + torch.cuda.synchronize() + start_time = time.perf_counter() + for _ in range(num_iterations): + translations, orientations_local = xform_view.get_local_poses() + if is_newton: + torch.cuda.synchronize() + timing_results["get_local_poses"] = (time.perf_counter() - start_time) / num_iterations + + translations_t = to_torch(translations) + orientations_local_t = to_torch(orientations_local) + computed_results["initial_local_translations"] = translations_t.clone() + computed_results["initial_local_orientations"] = orientations_local_t.clone() + + # -- set_local_poses ----------------------------------------------- + if is_newton: + new_translations = wp.clone(translations) + wp.to_torch(new_translations)[:, 2] += 0.1 + else: + new_translations = translations_t.clone() + new_translations[:, 2] += 0.1 + + if is_newton: + torch.cuda.synchronize() + start_time = time.perf_counter() + for _ in range(num_iterations): xform_view.set_local_poses(new_translations, orientations_local) - elif api == "isaacsim-exp": - xform_view.set_local_poses(new_translations.cpu().numpy(), orientations_local.cpu().numpy()) - timing_results["set_local_poses"] = (time.perf_counter() - start_time) / num_iterations - - # Get local poses after setting to verify - translations_after_set, orientations_local_after_set = xform_view.get_local_poses() - if not isinstance(translations_after_set, torch.Tensor): - translations_after_set = torch.tensor(translations_after_set, dtype=torch.float32) - if not isinstance(orientations_local_after_set, torch.Tensor): - orientations_local_after_set = torch.tensor(orientations_local_after_set, dtype=torch.float32) - computed_results["local_translations_after_set"] = translations_after_set.clone() - computed_results["local_orientations_after_set"] = orientations_local_after_set.clone() - - # Benchmark combined get operation - # Warmup call (Fabric should already be initialized by now, but for consistency) - positions, orientations = xform_view.get_world_poses() - translations, local_orientations = xform_view.get_local_poses() - - # Now time the actual iterations - start_time = time.perf_counter() - for _ in range(num_iterations): - positions, orientations = xform_view.get_world_poses() - translations, local_orientations = xform_view.get_local_poses() - timing_results["get_both"] = (time.perf_counter() - start_time) / num_iterations - - # Benchmark interleaved set/get (realistic workflow pattern) - # Pre-convert tensors for experimental API to avoid conversion overhead in loop - if api == "isaacsim-exp": - new_positions_np = new_positions.cpu().numpy() - orientations_np = orientations - - # Warmup - if api in ("isaaclab-usd", "isaaclab-fabric", "isaacsim-usd", "isaacsim-fabric"): - xform_view.set_world_poses(new_positions, orientations) - positions, orientations = xform_view.get_world_poses() - elif api == "isaacsim-exp": - xform_view.set_world_poses(new_positions_np, orientations_np) - positions, orientations = xform_view.get_world_poses() - positions = torch.tensor(positions, dtype=torch.float32) - orientations = torch.tensor(orientations, dtype=torch.float32) - - # Now time the actual interleaved iterations - start_time = time.perf_counter() - for _ in range(num_iterations): - # Write then immediately read (common pattern: set pose, verify/query result) - if api in ("isaaclab-usd", "isaaclab-fabric", "isaacsim-usd", "isaacsim-fabric"): + if is_newton: + torch.cuda.synchronize() + timing_results["set_local_poses"] = (time.perf_counter() - start_time) / num_iterations + + ta, ola = xform_view.get_local_poses() + computed_results["local_translations_after_set"] = to_torch(ta).clone() + computed_results["local_orientations_after_set"] = to_torch(ola).clone() + + # -- get_both (world + local) -------------------------------------- + if is_newton: + torch.cuda.synchronize() + start_time = time.perf_counter() + for _ in range(num_iterations): + xform_view.get_world_poses() + xform_view.get_local_poses() + if is_newton: + torch.cuda.synchronize() + timing_results["get_both"] = (time.perf_counter() - start_time) / num_iterations + + # -- interleaved set -> get ---------------------------------------- + if is_newton: + torch.cuda.synchronize() + start_time = time.perf_counter() + for _ in range(num_iterations): xform_view.set_world_poses(new_positions, orientations) - positions, orientations = xform_view.get_world_poses() - elif api == "isaacsim-exp": - xform_view.set_world_poses(new_positions_np, orientations_np) - positions, orientations = xform_view.get_world_poses() + xform_view.get_world_poses() + if is_newton: + torch.cuda.synchronize() + timing_results["interleaved_world_set_get"] = (time.perf_counter() - start_time) / num_iterations - timing_results["interleaved_world_set_get"] = (time.perf_counter() - start_time) / num_iterations - - # close simulation - sim.clear_instance() + finally: + if cleanup: + cleanup() return timing_results, computed_results -def compare_results( - results_dict: dict[str, dict[str, torch.Tensor]], tolerance: float = 1e-4 -) -> dict[str, dict[str, dict[str, float]]]: - """Compare computed results across multiple implementations. - - Only compares implementations using the same data path: - - USD implementations (isaaclab-usd, isaacsim-usd) are compared with each other - - Fabric implementations (isaaclab-fabric, isaacsim-fabric) are compared with each other - - This is because Fabric is designed for write-first workflows and may not match - USD reads on initialization. - - Args: - results_dict: Dictionary mapping API names to their computed values. - tolerance: Tolerance for numerical comparison. - - Returns: - Nested dictionary: {comparison_pair: {metric: {stats}}}, e.g., - {"isaaclab-usd_vs_isaacsim-usd": {"initial_world_positions": {"max_diff": 0.001, ...}}} - """ - comparison_stats = {} - - # Group APIs by their data path (USD vs Fabric) - usd_apis = [api for api in results_dict.keys() if "usd" in api and "fabric" not in api] - fabric_apis = [api for api in results_dict.keys() if "fabric" in api] - - # Compare within USD group - for i, api1 in enumerate(usd_apis): - for api2 in usd_apis[i + 1 :]: - pair_key = f"{api1}_vs_{api2}" - comparison_stats[pair_key] = {} - - computed1 = results_dict[api1] - computed2 = results_dict[api2] - - for key in computed1.keys(): - if key not in computed2: - print(f" Warning: Key '{key}' not found in {api2} results") - continue - - val1 = computed1[key] - val2 = computed2[key] - - # Compute differences - diff = torch.abs(val1 - val2) - max_diff = torch.max(diff).item() - mean_diff = torch.mean(diff).item() - - # Check if within tolerance - all_close = torch.allclose(val1, val2, atol=tolerance, rtol=0) - - comparison_stats[pair_key][key] = { - "max_diff": max_diff, - "mean_diff": mean_diff, - "all_close": all_close, - } - - # Compare within Fabric group - for i, api1 in enumerate(fabric_apis): - for api2 in fabric_apis[i + 1 :]: - pair_key = f"{api1}_vs_{api2}" - comparison_stats[pair_key] = {} - - computed1 = results_dict[api1] - computed2 = results_dict[api2] - - for key in computed1.keys(): - if key not in computed2: - print(f" Warning: Key '{key}' not found in {api2} results") - continue - - val1 = computed1[key] - val2 = computed2[key] - - # Compute differences - diff = torch.abs(val1 - val2) - max_diff = torch.max(diff).item() - mean_diff = torch.mean(diff).item() - - # Check if within tolerance - all_close = torch.allclose(val1, val2, atol=tolerance, rtol=0) - - comparison_stats[pair_key][key] = { - "max_diff": max_diff, - "mean_diff": mean_diff, - "all_close": all_close, - } - - return comparison_stats - - -def print_comparison_results(comparison_stats: dict[str, dict[str, dict[str, float]]], tolerance: float): - """Print comparison results across implementations. - - Args: - comparison_stats: Nested dictionary containing comparison statistics for each API pair. - tolerance: Tolerance used for comparison. - """ - if not comparison_stats: - print("\n" + "=" * 100) - print("RESULT COMPARISON") - print("=" * 100) - print("â„đïļ No comparisons performed.") - print(" USD and Fabric implementations are not compared because Fabric uses a") - print(" write-first workflow and may not match USD reads on initialization.") - print("=" * 100) - print() - return - - for pair_key, pair_stats in comparison_stats.items(): - # Format the pair key for display (e.g., "isaaclab_vs_isaacsim" -> "Isaac Lab vs Isaac Sim") - api1, api2 = pair_key.split("_vs_") - display_api1 = api1.replace("-", " ").title() - display_api2 = api2.replace("-", " ").title() - comparison_title = f"{display_api1} vs {display_api2}" - - # Check if all results match - all_match = all(stats["all_close"] for stats in pair_stats.values()) - - if all_match: - # Compact output when everything matches - print("\n" + "=" * 100) - print(f"RESULT COMPARISON: {comparison_title}") - print("=" * 100) - print(f"✓ All computed values match within tolerance ({tolerance})") - print("=" * 100) - else: - # Detailed output when there are mismatches - print("\n" + "=" * 100) - print(f"RESULT COMPARISON: {comparison_title}") - print("=" * 100) - print(f"{'Computed Value':<40} {'Max Diff':<15} {'Mean Diff':<15} {'Match':<10}") - print("-" * 100) - - for key, stats in pair_stats.items(): - # Format the key for display - display_key = key.replace("_", " ").title() - match_str = "✓ Yes" if stats["all_close"] else "✗ No" - - print(f"{display_key:<40} {stats['max_diff']:<15.6e} {stats['mean_diff']:<15.6e} {match_str:<10}") - - print("=" * 100) - print(f"\n✗ Some results differ beyond tolerance ({tolerance})") - - # Special note for Isaac Sim Fabric local pose bug - if "isaacsim-fabric" in pair_key and any("local_translations_after_set" in k for k in pair_stats.keys()): - if not pair_stats.get("local_translations_after_set", {}).get("all_close", True): - print("\n ⚠ïļ Known Issue: Isaac Sim Fabric has a bug where get_local_poses() returns stale") - print(" values after set_local_poses(). Isaac Lab Fabric correctly returns updated values.") - print(" This is a correctness issue in Isaac Sim's implementation, not Isaac Lab's.") - else: - print(f" This may indicate implementation differences between {display_api1} and {display_api2}") - - print() +# ------------------------------------------------------------------ +# Reporting +# ------------------------------------------------------------------ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num_iterations: int): - """Print benchmark results in a formatted table. - - Args: - results_dict: Dictionary mapping API names to their timing results. - num_prims: Number of prims tested. - num_iterations: Number of iterations run. - """ - print("\n" + "=" * 100) + """Print benchmark results in a formatted table.""" + print("\n" + "=" * 120) print(f"BENCHMARK RESULTS: {num_prims} prims, {num_iterations} iterations") - print("=" * 100) + print("=" * 120) api_names = list(results_dict.keys()) - # Format API names for display - display_names = [name.replace("-", " ").replace("_", " ").title() for name in api_names] - - # Calculate column width based on number of APIs - col_width = 20 + display_names = [name.replace("-", " ").title() for name in api_names] + col_width = 22 - # Print header - header = f"{'Operation':<25}" - for display_name in display_names: - header += f" {display_name + ' (ms)':<{col_width}}" + header = f"{'Operation':<28}" + for dn in display_names: + header += f" {dn + ' (ms)':>{col_width}}" print(header) - print("-" * 100) + print("-" * 120) - # Print each operation operations = [ ("Initialization", "init"), ("Get World Poses", "get_world_poses"), @@ -469,159 +274,119 @@ def print_results(results_dict: dict[str, dict[str, float]], num_prims: int, num ("Get Local Poses", "get_local_poses"), ("Set Local Poses", "set_local_poses"), ("Get Both (World+Local)", "get_both"), - ("Interleaved World Set→Get", "interleaved_world_set_get"), + ("Interleaved World Set->Get", "interleaved_world_set_get"), ] for op_name, op_key in operations: - row = f"{op_name:<25}" - for api_name in api_names: - api_time = results_dict[api_name].get(op_key, 0) * 1000 # Convert to ms - row += f" {api_time:>{col_width - 1}.4f}" + row = f"{op_name:<28}" + for name in api_names: + val = results_dict[name].get(op_key, 0) * 1000 + row += f" {val:>{col_width}.4f}" print(row) - print("=" * 100) + print("=" * 120) - # Calculate and print total time - total_row = f"{'Total Time':<25}" - for api_name in api_names: - total_time = sum(results_dict[api_name].values()) * 1000 - total_row += f" {total_time:>{col_width - 1}.4f}" + total_row = f"{'Total':<28}" + for name in api_names: + total_row += f" {sum(results_dict[name].values()) * 1000:>{col_width}.4f}" print(f"\n{total_row}") - # Calculate speedups relative to Isaac Lab USD (baseline) - if "isaaclab-usd" in api_names: - print("\n" + "=" * 100) - print("SPEEDUP vs Isaac Lab USD (Baseline)") - print("=" * 100) - print(f"{'Operation':<25}", end="") - for api_name, display_name in zip(api_names, display_names): - if api_name != "isaaclab-usd": - print(f" {display_name:<{col_width}}", end="") - print() - print("-" * 100) - - isaaclab_usd_results = results_dict["isaaclab-usd"] + baseline = "isaaclab-usd" + if baseline in results_dict and len(api_names) > 1: + print("\n" + "=" * 120) + print(f"SPEEDUP vs {baseline.replace('-', ' ').title()}") + print("=" * 120) + header = f"{'Operation':<28}" + for name in api_names: + if name != baseline: + header += f" {name.replace('-', ' ').title():>{col_width}}" + print(header) + print("-" * 120) + + base = results_dict[baseline] for op_name, op_key in operations: - print(f"{op_name:<25}", end="") - isaaclab_usd_time = isaaclab_usd_results.get(op_key, 0) - for api_name, display_name in zip(api_names, display_names): - if api_name != "isaaclab-usd": - api_time = results_dict[api_name].get(op_key, 0) - if isaaclab_usd_time > 0 and api_time > 0: - speedup = isaaclab_usd_time / api_time - print(f" {speedup:>{col_width - 1}.2f}x", end="") + row = f"{op_name:<28}" + base_t = base.get(op_key, 0) + for name in api_names: + if name != baseline: + impl_t = results_dict[name].get(op_key, 0) + if base_t > 0 and impl_t > 0: + row += f" {base_t / impl_t:>{col_width}.2f}x" else: - print(f" {'N/A':>{col_width}}", end="") - print() - - # Overall speedup - print("=" * 100) - print(f"{'Overall Speedup':<25}", end="") - total_isaaclab_usd = sum(isaaclab_usd_results.values()) - for api_name, display_name in zip(api_names, display_names): - if api_name != "isaaclab-usd": - total_api = sum(results_dict[api_name].values()) - if total_isaaclab_usd > 0 and total_api > 0: - overall_speedup = total_isaaclab_usd / total_api - print(f" {overall_speedup:>{col_width - 1}.2f}x", end="") + row += f" {'N/A':>{col_width}}" + print(row) + + print("=" * 120) + print(f"{'Overall':>28}", end="") + total_base = sum(base.values()) + for name in api_names: + if name != baseline: + total_impl = sum(results_dict[name].values()) + if total_base > 0 and total_impl > 0: + print(f" {total_base / total_impl:>{col_width}.2f}x", end="") else: print(f" {'N/A':>{col_width}}", end="") print() - print("\n" + "=" * 100) + print("\n" + "=" * 120) print("\nNotes:") print(" - Times are averaged over all iterations") - print(" - Speedup = (Isaac Lab USD time) / (Other API time)") - print(" - Speedup > 1.0 means the other API is faster than Isaac Lab USD") - print(" - Speedup < 1.0 means the other API is slower than Isaac Lab USD") + print(" - Speedup > 1.0 means faster than USD baseline") print() def main(): - """Main benchmark function.""" - print("=" * 100) - print("XformPrimView Benchmark - Comparing Multiple APIs") - print("=" * 100) - print("Configuration:") - print(f" Number of environments: {args_cli.num_envs}") - print(f" Iterations per test: {args_cli.num_iterations}") - print(f" Device: {args_cli.device}") - print(f" Profiling: {'Enabled' if args_cli.profile else 'Disabled'}") - if args_cli.profile: - print(f" Profile directory: {args_cli.profile_dir}") + print("=" * 120) + print("FrameView Benchmark") + print("=" * 120) + print(f" Environments: {args_cli.num_envs}") + print(f" Iterations: {args_cli.num_iterations}") + print(f" Device: {args_cli.device}") print() - # Create profile directory if profiling is enabled if args_cli.profile: import os os.makedirs(args_cli.profile_dir, exist_ok=True) - # Dictionary to store all results - all_timing_results = {} - all_computed_results = {} + all_timing = {} + all_computed = {} profile_files = {} - # APIs to benchmark - apis_to_test = [ - ("isaaclab-usd", "Isaac Lab XformPrimView (USD)"), - ("isaaclab-fabric", "Isaac Lab XformPrimView (Fabric)"), - ("isaacsim-usd", "Isaac Sim XformPrimView (USD)"), - ("isaacsim-fabric", "Isaac Sim XformPrimView (Fabric)"), - ("isaacsim-exp", "Isaac Sim Experimental XformPrim"), + apis = [ + ("isaaclab-usd", "Isaac Lab FrameView (USD)"), + ("isaaclab-fabric", "Isaac Lab FrameView (Fabric)"), + ("isaaclab-newton-site", "Isaac Lab FrameView (Newton Site)"), ] - # Benchmark each API - for api_key, api_name in apis_to_test: + for api_key, api_name in apis: print(f"Benchmarking {api_name}...") if args_cli.profile: profiler = cProfile.Profile() profiler.enable() - # Cast api_key to Literal type for type checker - timing, computed = benchmark_xform_prim_view( - api=api_key, # type: ignore[arg-type] - num_iterations=args_cli.num_iterations, - ) + timing, computed = benchmark_frame_view(api=api_key, num_iterations=args_cli.num_iterations) if args_cli.profile: profiler.disable() - profile_file = f"{args_cli.profile_dir}/{api_key.replace('-', '_')}_benchmark.prof" - profiler.dump_stats(profile_file) - profile_files[api_key] = profile_file - print(f" Profile saved to: {profile_file}") - - all_timing_results[api_key] = timing - all_computed_results[api_key] = computed - - print(" Done!") - print() + pf = f"{args_cli.profile_dir}/{api_key.replace('-', '_')}_benchmark.prof" + profiler.dump_stats(pf) + profile_files[api_key] = pf + print(f" Profile saved to: {pf}") - # Print timing results - print_results(all_timing_results, args_cli.num_envs, args_cli.num_iterations) + all_timing[api_key] = timing + all_computed[api_key] = computed + print(" Done!\n") - # Compare computed results - print("\nComparing computed results across APIs...") - comparison_stats = compare_results(all_computed_results, tolerance=1e-6) - print_comparison_results(comparison_stats, tolerance=1e-4) + print_results(all_timing, args_cli.num_envs, args_cli.num_iterations) - # Print profiling instructions if enabled if args_cli.profile: - print("\n" + "=" * 100) - print("PROFILING RESULTS") - print("=" * 100) - print("Profile files have been saved. To visualize with snakeviz, run:") - for api_key, profile_file in profile_files.items(): - api_display = api_key.replace("-", " ").title() - print(f" # {api_display}") - print(f" snakeviz {profile_file}") - print("\nAlternatively, use pstats to analyze in terminal:") - print(" python -m pstats ") - print("=" * 100) + print("\nProfile files:") + for key, pf in profile_files.items(): + print(f" snakeviz {pf}") print() - # Clean up sim_utils.SimulationContext.clear_instance() diff --git a/scripts/demos/sensors/raycaster_sensor.py b/scripts/demos/sensors/raycaster_sensor.py index 4f758274b61c..dd0b454ad636 100644 --- a/scripts/demos/sensors/raycaster_sensor.py +++ b/scripts/demos/sensors/raycaster_sensor.py @@ -62,7 +62,7 @@ class RaycasterSensorSceneCfg(InteractiveSceneCfg): robot = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") ray_caster = RayCasterCfg( - prim_path="{ENV_REGEX_NS}/Robot/base/lidar_cage", + prim_path="{ENV_REGEX_NS}/Robot/base", update_period=1 / 60, offset=RayCasterCfg.OffsetCfg(pos=(0, 0, 0.5)), mesh_prim_paths=["/World/Ground"], diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 7092d52f3ff7..86024cf07e6a 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.10" +version = "4.6.11" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 0251fcb03ca2..489be9f6aa15 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,40 @@ Changelog --------- +4.6.11 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Changed :class:`~isaaclab.sensors.RayCaster` to spawn its own non-physics Xform prim via + the new :attr:`~isaaclab.sensors.RayCasterCfg.spawn` attribute. ``prim_path`` should now + point to a child under the parent link (e.g. ``{ENV_REGEX_NS}/Robot/base/raycaster``). +* Renamed :class:`~isaaclab.sim.views.XformPrimView` to :class:`~isaaclab.sim.views.FrameView`, + ``BaseXformPrimView`` to :class:`~isaaclab.sim.views.BaseFrameView`, + and ``UsdXformPrimView`` to :class:`~isaaclab.sim.views.UsdFrameView`. + ``XformPrimView`` is kept as a deprecated alias. +* Moved :class:`~isaaclab.sensors.RayCasterCfg` offset into the spawned prim's local transform + instead of applying it at runtime. The :class:`~isaaclab.sim.views.FrameView` world pose now + includes the offset directly. +* Unified sensor prim path resolution in :class:`~isaaclab.sensors.SensorBase`. When + ``prim_path`` points at a physics body and a spawner is configured, a child prim is + automatically created underneath. + +Deprecated +^^^^^^^^^^ + +* Deprecated passing a ``prim_path`` with ``ArticulationRootAPI`` or ``RigidBodyAPI`` to + :class:`~isaaclab.sensors.RayCasterCfg`. The path is automatically extended with + ``/raycaster``; users should migrate to the child-path convention. + +Removed +^^^^^^^ + +* Removed :attr:`~isaaclab.sensors.RayCasterCfg.attach_yaw_only` (deprecated since 2.1.1). + Use ``ray_alignment="yaw"`` or ``ray_alignment="base"`` instead. + + 4.6.10 (2026-04-22) ~~~~~~~~~~~~~~~~~~~ @@ -381,6 +415,21 @@ Added Added ^^^^^ + +* Added :class:`~isaaclab.sim.views.BaseXformPrimView` abstract base class that defines + the common interface for backend-specific ``XformPrimView`` implementations. +* Added :class:`~isaaclab.sim.views.XformPrimView` factory to instantiate the correct + backend-specific ``XformPrimView`` based on the active simulation backend. + +Changed +^^^^^^^ + +* Refactored :class:`~isaaclab.sim.views.XformPrimView` to delegate backend-specific + logic to :class:`~isaaclab_physx.sim.views.FabricXformPrimView` and + :class:`~isaaclab_newton.sim.views.NewtonSiteXformPrimView`. The public API is + unchanged; use :class:`~isaaclab.sim.views.XformPrimView` for backend-aware + instantiation. + * Added release version to :class:`~isaaclab.test.benchmark.recorders.VersionInfoRecorder` output. diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index a4ac4424f485..da6f5eff75d7 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -32,7 +32,7 @@ from isaaclab.sensors import ContactSensorCfg, FrameTransformerCfg, SensorBase, SensorBaseCfg from isaaclab.sim import SimulationContext from isaaclab.sim.utils.stage import get_current_stage, get_current_stage_id -from isaaclab.sim.views import XformPrimView +from isaaclab.sim.views import FrameView from isaaclab.terrains import TerrainImporter, TerrainImporterCfg # Note: This is a temporary import for the VisuoTactileSensorCfg class. @@ -403,11 +403,11 @@ def surface_grippers(self) -> dict[str, SurfaceGripper]: return self._surface_grippers @property - def extras(self) -> dict[str, XformPrimView]: + def extras(self) -> dict[str, FrameView]: """A dictionary of miscellaneous simulation objects that neither inherit from assets nor sensors. The keys are the names of the miscellaneous objects, and the values are the - :class:`~isaaclab.sim.views.XformPrimView` instances of the corresponding prims. + :class:`~isaaclab.sim.views.FrameView` instances of the corresponding prims. As an example, lights or other props in the scene that do not have any attributes or properties that you want to alter at runtime can be added to this dictionary. @@ -833,7 +833,7 @@ def _add_entities_from_cfg(self): # noqa: C901 ) # store xform prim view corresponding to this asset # all prims in the scene are Xform prims (i.e. have a transform component) - self._extras[asset_name] = XformPrimView(asset_cfg.prim_path, device=self.device, stage=self.stage) + self._extras[asset_name] = FrameView(asset_cfg.prim_path, device=self.device, stage=self.stage) else: raise ValueError(f"Unknown asset config type for {asset_name}: {asset_cfg}") diff --git a/source/isaaclab/isaaclab/sensors/__init__.py b/source/isaaclab/isaaclab/sensors/__init__.py index 1128b11c1202..717fc4a7163c 100644 --- a/source/isaaclab/isaaclab/sensors/__init__.py +++ b/source/isaaclab/isaaclab/sensors/__init__.py @@ -26,7 +26,7 @@ +---------------------+---------------------------+---------------------------------------------------------------+ | Contact Sensor | /World/robot/feet_* | Leaf is available and checks if the schema exists | +---------------------+---------------------------+---------------------------------------------------------------+ -| Ray Caster | /World/robot/base | Leaf exists and is a physics body (Articulation / Rigid Body) | +| Ray Caster | /World/robot/base/raycast | ``spawn`` creates an Xform leaf; else the leaf must exist | +---------------------+---------------------------+---------------------------------------------------------------+ | Frame Transformer | /World/robot/base | Leaf exists and is a physics body (Articulation / Rigid Body) | +---------------------+---------------------------+---------------------------------------------------------------+ diff --git a/source/isaaclab/isaaclab/sensors/camera/camera.py b/source/isaaclab/isaaclab/sensors/camera/camera.py index eb588489f729..22c96af1779e 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera.py @@ -16,11 +16,10 @@ from pxr import Sdf, UsdGeom -import isaaclab.sim as sim_utils import isaaclab.utils.sensors as sensor_utils from isaaclab.app.settings_manager import get_settings_manager from isaaclab.renderers import BaseRenderer, Renderer -from isaaclab.sim.views import XformPrimView +from isaaclab.sim.views import FrameView from isaaclab.utils import has_kit, to_camel_case from isaaclab.utils.math import ( convert_camera_frame_orientation_convention, @@ -121,36 +120,15 @@ def __init__(self, cfg: CameraCfg): settings = get_settings_manager() settings.set_bool("/isaaclab/render/rtx_sensors", True) - # spawn the asset - if self.cfg.spawn is not None: - # Use spawn_path when set (points to template location for scene-cloned sensors). - # This allows the camera to be spawned inside the asset template (e.g. inside - # proto_asset_0) before clone_environments replicates it to all env paths. - spawn_target = ( - self.cfg.spawn.spawn_path - if getattr(self.cfg.spawn, "spawn_path", None) is not None - else self.cfg.prim_path - ) - # compute the rotation offset - rot = torch.tensor(self.cfg.offset.rot, dtype=torch.float32, device="cpu").unsqueeze(0) - rot_offset = convert_camera_frame_orientation_convention( - rot, origin=self.cfg.offset.convention, target="opengl" - ) - rot_offset = rot_offset.squeeze(0).cpu().numpy() - # ensure vertical aperture is set, otherwise replace with default for squared pixels - if self.cfg.spawn.vertical_aperture is None: - self.cfg.spawn.vertical_aperture = self.cfg.spawn.horizontal_aperture * self.cfg.height / self.cfg.width - self.cfg.spawn.func(spawn_target, self.cfg.spawn, translation=self.cfg.offset.pos, orientation=rot_offset) - # check that spawn was successful; use spawn_path if set (template location) since env - # paths are not yet populated at init time — they are filled in by clone_environments. - check_path = ( - self.cfg.spawn.spawn_path - if self.cfg.spawn is not None and getattr(self.cfg.spawn, "spawn_path", None) is not None - else self.cfg.prim_path + # Compute camera orientation (convention conversion) and spawn + rot = torch.tensor(self.cfg.offset.rot, dtype=torch.float32, device="cpu").unsqueeze(0) + rot_offset = convert_camera_frame_orientation_convention( + rot, origin=self.cfg.offset.convention, target="opengl" ) - matching_prims = sim_utils.find_matching_prims(check_path) - if len(matching_prims) == 0: - raise RuntimeError(f"Could not find prim with path {check_path}.") + rot_offset = rot_offset.squeeze(0).cpu().numpy() + if self.cfg.spawn is not None and self.cfg.spawn.vertical_aperture is None: + self.cfg.spawn.vertical_aperture = self.cfg.spawn.horizontal_aperture * self.cfg.height / self.cfg.width + self._resolve_and_spawn("camera", translation=self.cfg.offset.pos, orientation=rot_offset) # UsdGeom Camera prim for the sensor self._sensor_prims: list[UsdGeom.Camera] = list() @@ -335,8 +313,16 @@ def set_world_poses( elif not isinstance(orientations, torch.Tensor): orientations = torch.tensor(orientations, device=self._device) orientations = convert_camera_frame_orientation_convention(orientations, origin=convention, target="opengl") - # set the pose - self._view.set_world_poses(positions, orientations, env_ids) + # convert torch tensors to warp arrays for the view + pos_wp = wp.from_torch(positions.contiguous()) if positions is not None else None + ori_wp = wp.from_torch(orientations.contiguous()) if orientations is not None else None + if env_ids is not None: + if not isinstance(env_ids, torch.Tensor): + env_ids = torch.tensor(env_ids, dtype=torch.int32, device=self._device) + idx_wp = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) + else: + idx_wp = None + self._view.set_world_poses(pos_wp, ori_wp, idx_wp) def set_world_poses_from_view( self, eyes: torch.Tensor, targets: torch.Tensor, env_ids: Sequence[int] | None = None @@ -359,7 +345,10 @@ def set_world_poses_from_view( up_axis = UsdGeom.GetStageUpAxis(self.stage) # set camera poses using the view orientations = quat_from_matrix(create_rotation_matrix_from_view(eyes, targets, up_axis, device=self._device)) - self._view.set_world_poses(eyes, orientations, env_ids) + if not isinstance(env_ids, torch.Tensor): + env_ids = torch.tensor(env_ids, dtype=torch.int32, device=self._device) + idx_wp = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) + self._view.set_world_poses(wp.from_torch(eyes.contiguous()), wp.from_torch(orientations.contiguous()), idx_wp) """ Operations @@ -418,9 +407,7 @@ def _initialize_impl(self): # Create a view for the sensor with Fabric enabled for fast pose queries. # TODO: remove sync_usd_on_fabric_write=True once the GPU Fabric sync bug is fixed. - self._view = XformPrimView( - self.cfg.prim_path, device=self._device, stage=self.stage, sync_usd_on_fabric_write=True - ) + self._view = FrameView(self.cfg.prim_path, device=self._device, stage=self.stage, sync_usd_on_fabric_write=True) # Check that sizes are correct if self._view.count != self._num_envs: raise RuntimeError( @@ -612,11 +599,14 @@ def _update_poses(self, env_ids: Sequence[int]): if len(self._sensor_prims) == 0: raise RuntimeError("Camera prim is None. Please call 'sim.play()' first.") - # get the poses from the view - poses, quat = self._view.get_world_poses(env_ids) - self._data.pos_w[env_ids] = poses + # get the poses from the view (returns wp.array, convert to torch) + if env_ids is not None and not isinstance(env_ids, torch.Tensor): + env_ids = torch.tensor(env_ids, dtype=torch.int32, device=self._device) + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None + pos_wp, quat_wp = self._view.get_world_poses(indices) + self._data.pos_w[env_ids] = wp.to_torch(pos_wp) self._data.quat_w_world[env_ids] = convert_camera_frame_orientation_convention( - quat, origin="opengl", target="world" + wp.to_torch(quat_wp), origin="opengl", target="world" ) # notify renderer of updated poses (guarded in case called before initialization completes) if self._render_data is not None: diff --git a/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py b/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py index 1b5070cfd214..efd8e1f304c1 100644 --- a/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py +++ b/source/isaaclab/isaaclab/sensors/camera/camera_cfg.py @@ -85,7 +85,7 @@ class OffsetCfg: """Whether to update the latest camera pose when fetching the camera's data. Defaults to False. If True, the latest camera pose is updated in the camera's data which will slow down performance - due to the use of :class:`XformPrimView`. + due to the use of :class:`FrameView`. If False, the pose of the camera during initialization is returned. """ diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster.py b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster.py index 002456b6e101..9f3d692b13cd 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster.py @@ -14,18 +14,15 @@ import trimesh import warp as wp -import omni.physics.tensors.impl.api as physx - import isaaclab.sim as sim_utils -from isaaclab.sim.views import XformPrimView -from isaaclab.utils.math import combine_frame_transforms, matrix_from_quat +from isaaclab.sim.views import BaseFrameView, FrameView +from isaaclab.utils.math import matrix_from_quat from isaaclab.utils.mesh import PRIMITIVE_MESH_TYPES, create_trimesh_from_geom_mesh, create_trimesh_from_geom_shape from isaaclab.utils.warp import convert_to_warp_mesh from isaaclab.utils.warp import kernels as warp_kernels from .kernels import fill_float2d_masked_kernel, fill_vec3_inf_kernel from .multi_mesh_ray_caster_data import MultiMeshRayCasterData -from .ray_cast_utils import obtain_world_pose_from_view from .ray_caster import RayCaster if TYPE_CHECKING: @@ -86,12 +83,7 @@ class MultiMeshRayCaster(RayCaster): cfg: MultiMeshRayCasterCfg """The configuration parameters.""" - mesh_offsets: ClassVar[dict[str, tuple[torch.Tensor, torch.Tensor]]] = {} - """Per-mesh position and orientation offsets relative to their physics views, shared across instances. - - Keys are prim path expressions; values are ``(pos_offset, ori_offset)`` tuples.""" - - mesh_views: ClassVar[dict[str, XformPrimView | physx.ArticulationView | physx.RigidBodyView]] = {} + mesh_views: ClassVar[dict[str, BaseFrameView]] = {} """A dictionary to store mesh views for raycasting, shared across all instances. The keys correspond to the prim path for the mesh views, and values are the corresponding view objects. @@ -281,8 +273,8 @@ def _initialize_warp_meshes(self): mesh_idx += n_meshes_per_env if target_cfg.track_mesh_transforms: - MultiMeshRayCaster.mesh_views[target_prim_path], MultiMeshRayCaster.mesh_offsets[target_prim_path] = ( - self._obtain_trackable_prim_view(target_prim_path) + MultiMeshRayCaster.mesh_views[target_prim_path] = FrameView( + target_prim_path, device=self._device, stage=self.stage ) if all([target_cfg.prim_expr not in multi_mesh_ids for target_cfg in self._raycast_targets_cfg]): @@ -357,19 +349,12 @@ def _update_mesh_transforms(self) -> None: mesh_idx += self._num_meshes_per_env[target_cfg.prim_expr] continue - pos_w, ori_w = obtain_world_pose_from_view(view, None) + # update position of the target meshes + pos_wp, quat_wp = view.get_world_poses(None) + pos_w, ori_w = wp.to_torch(pos_wp), wp.to_torch(quat_wp) pos_w = pos_w.squeeze(0) if len(pos_w.shape) == 3 else pos_w ori_w = ori_w.squeeze(0) if len(ori_w.shape) == 3 else ori_w - if target_cfg.prim_expr in MultiMeshRayCaster.mesh_offsets: - pos_offset, ori_offset = MultiMeshRayCaster.mesh_offsets[target_cfg.prim_expr] - pos_w, ori_w = combine_frame_transforms( - pos_w, - ori_w, - pos_offset.expand(pos_w.shape[0], -1), - ori_offset.expand(ori_w.shape[0], -1), - ) - count = view.count if count != 1: count = count // self._num_envs @@ -434,7 +419,6 @@ def _invalidate_initialize_callback(self, event): def __del__(self): super().__del__() if RayCaster._instance_count == 0: - MultiMeshRayCaster.mesh_offsets.clear() MultiMeshRayCaster.mesh_views.clear() diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera.py b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera.py index d5e084abb32e..f184c28b20e2 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/multi_mesh_ray_caster_camera.py @@ -21,7 +21,6 @@ ) from .multi_mesh_ray_caster import MultiMeshRayCaster from .multi_mesh_ray_caster_camera_data import MultiMeshRayCasterCameraData -from .ray_cast_utils import obtain_world_pose_from_view from .ray_caster_camera import RayCasterCamera if TYPE_CHECKING: @@ -178,7 +177,9 @@ def _update_ray_infos(self, env_mask: wp.array): return # Compute camera world poses by composing view pose with sensor offset - pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids) + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) + pos_wp, quat_wp = self._view.get_world_poses(indices) + pos_w, quat_w = wp.to_torch(pos_wp), wp.to_torch(quat_wp) pos_w, quat_w = math_utils.combine_frame_transforms( pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] ) diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_cast_utils.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_cast_utils.py deleted file mode 100644 index ac503b28bf52..000000000000 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_cast_utils.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Utility functions for ray-cast sensors.""" - -from __future__ import annotations - -import torch -import warp as wp - -import omni.physics.tensors.impl.api as physx - -from isaaclab.sim.views import XformPrimView - - -def obtain_world_pose_from_view( - physx_view: XformPrimView | physx.ArticulationView | physx.RigidBodyView, - env_ids: torch.Tensor, - clone: bool = False, -) -> tuple[torch.Tensor, torch.Tensor]: - """Get the world poses of the prim referenced by the prim view. - - Args: - physx_view: The prim view to get the world poses from. - env_ids: The environment ids of the prims to get the world poses for. - clone: Whether to clone the returned tensors (default: False). - - Returns: - A tuple containing the world positions and orientations of the prims. - Orientation is in (x, y, z, w) format. - - Raises: - NotImplementedError: If the prim view is not of the supported type. - """ - if isinstance(physx_view, XformPrimView): - pos_w, quat_w = physx_view.get_world_poses(env_ids) - elif isinstance(physx_view, physx.ArticulationView): - pos_w, quat_w = wp.to_torch(physx_view.get_root_transforms())[env_ids].split([3, 4], dim=-1) - elif isinstance(physx_view, physx.RigidBodyView): - pos_w, quat_w = wp.to_torch(physx_view.get_transforms())[env_ids].split([3, 4], dim=-1) - else: - raise NotImplementedError(f"Cannot get world poses for prim view of type '{type(physx_view)}'.") - - if clone: - return pos_w.clone(), quat_w.clone() - else: - return pos_w, quat_w diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py index 89cc9aaa674f..1e23ee00c1b6 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster.py @@ -13,13 +13,12 @@ import torch import warp as wp -import omni.physics.tensors.impl.api as physx -from pxr import Gf, Usd, UsdGeom, UsdPhysics +from pxr import Gf, Usd, UsdGeom import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils from isaaclab.markers import VisualizationMarkers -from isaaclab.sim.views import XformPrimView +from isaaclab.sim.views import FrameView from isaaclab.terrains.trimesh.utils import make_plane from isaaclab.utils.warp import convert_to_warp_mesh from isaaclab.utils.warp.kernels import raycast_mesh_masked_kernel @@ -74,6 +73,8 @@ def __init__(self, cfg: RayCasterCfg): """ RayCaster._instance_count += 1 super().__init__(cfg) + # Resolve physics-body paths and spawn the sensor Xform child if needed. + self._resolve_and_spawn("raycaster") self._data = RayCasterData() def __str__(self) -> str: @@ -135,42 +136,20 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None def _initialize_impl(self): super()._initialize_impl() - # obtain global simulation view - self._physics_sim_view = sim_utils.SimulationContext.instance().physics_manager.get_physics_sim_view() - prim = sim_utils.find_first_matching_prim(self.cfg.prim_path) - if prim is None: - available_prims = ",".join([str(p.GetPath()) for p in sim_utils.get_current_stage().Traverse()]) - raise RuntimeError( - f"Failed to find a prim at path expression: {self.cfg.prim_path}. Available prims: {available_prims}" - ) - - self._view, self._offset = self._obtain_trackable_prim_view(self.cfg.prim_path) - - # Convert offsets to warp (zero-copy from existing torch tensors). - # Store the contiguous tensors explicitly so they are not garbage-collected while - # the wp.array views (_offset_pos_wp / _offset_quat_wp) are alive. If the tensor - # returned by .contiguous() is a temporary copy (non-contiguous input), the warp - # view would otherwise point to freed memory once GC reclaims it. - self._offset_pos_contiguous = self._offset[0].contiguous() - self._offset_quat_contiguous = self._offset[1].contiguous() - self._offset_pos_wp = wp.from_torch(self._offset_pos_contiguous, dtype=wp.vec3f) + # Build a FrameView over the sensor prim paths. The FrameView tracks the spawned + # (non-physics) Xform directly, so no physics-body redirect or offset resolution + # is needed at runtime — the world pose returned already includes any offset + # baked into the prim's local transform. + self._view = FrameView(self.cfg.prim_path, device=self._device, stage=self.stage) + + # Per-env identity offsets (kept for kernel ABI compatibility): the sensor frame is + # already the FrameView's tracked prim, so no additional view-to-sensor offset applies. + self._offset_pos_wp = wp.zeros(self._view.count, dtype=wp.vec3f, device=self._device) + identity_quat = torch.zeros(self._view.count, 4, device=self._device) + identity_quat[:, 3] = 1.0 + self._offset_quat_contiguous = identity_quat.contiguous() self._offset_quat_wp = wp.from_torch(self._offset_quat_contiguous, dtype=wp.quatf) - # Handle deprecated attach_yaw_only at init time - if self.cfg.attach_yaw_only is not None: - msg = ( - "Raycaster attribute 'attach_yaw_only' property will be deprecated in a future release." - " Please use the parameter 'ray_alignment' instead." - ) - if self.cfg.attach_yaw_only: - self.cfg.ray_alignment = "yaw" - msg += " Setting ray_alignment to 'yaw'." - else: - self.cfg.ray_alignment = "base" - msg += " Setting ray_alignment to 'base'." - logger.warning(msg) - self.cfg.attach_yaw_only = None - # Resolve alignment mode to integer constant for kernel dispatch alignment_map = {"world": 0, "yaw": 1, "base": 2} if self.cfg.ray_alignment not in alignment_map: @@ -278,23 +257,18 @@ def _initialize_rays_impl(self): self._dummy_ray_normal = wp.empty((1, 1), dtype=wp.vec3f, device=self._device) def _get_view_transforms_wp(self) -> wp.array: - """Get world transforms from the physics view as a warp array. + """Get world transforms from the frame view as a warp array of ``wp.transformf``. Returns: - Warp array of ``wp.transformf`` with shape (num_envs,). + Warp array of ``wp.transformf`` with shape ``(num_envs,)``. Layout is + ``(tx, ty, tz, qx, qy, qz, qw)`` per element, matching the quaternion + convention returned by :class:`~isaaclab.sim.views.FrameView`. """ - if isinstance(self._view, XformPrimView): - # XformPrimView.get_world_poses() returns quaternions in (x, y, z, w) convention, - # which matches the wp.transformf layout (translation then xyzw quaternion). - pos_w, quat_w = self._view.get_world_poses() - poses = torch.cat([pos_w, quat_w], dim=-1).contiguous() - return wp.from_torch(poses).view(wp.transformf) - elif isinstance(self._view, physx.ArticulationView): - return self._view.get_root_transforms().view(wp.transformf) - elif isinstance(self._view, physx.RigidBodyView): - return self._view.get_transforms().view(wp.transformf) - else: - raise NotImplementedError(f"Cannot get transforms for view type '{type(self._view)}'.") + pos_wp, quat_wp = self._view.get_world_poses() + pos_torch = wp.to_torch(pos_wp).reshape(-1, 3) + quat_torch = wp.to_torch(quat_wp).reshape(-1, 4) + poses = torch.cat([pos_torch, quat_torch], dim=-1).contiguous() + return wp.from_torch(poses).view(wp.transformf) def _update_ray_infos(self, env_mask: wp.array): """Updates sensor poses and ray world-frame buffers via a single warp kernel.""" @@ -385,86 +359,6 @@ def _debug_vis_callback(self, event): self.ray_visualizer.visualize(viz_points) - """ - Internal Helpers. - """ - - def _obtain_trackable_prim_view( - self, target_prim_path: str - ) -> tuple[XformPrimView | physx.ArticulationView | physx.RigidBodyView, tuple[torch.Tensor, torch.Tensor]]: - """Obtain a prim view that can be used to track the pose of the target prim. - - The target prim path is a regex expression that matches one or more mesh prims. While we can track its - pose directly using XFormPrim, this is not efficient and can be slow. Instead, we create a prim view - using the physics simulation view, which provides a more efficient way to track the pose of the mesh prims. - - The function additionally resolves the relative pose between the mesh and its corresponding physics prim. - This is especially useful if the mesh is not directly parented to the physics prim. - - Args: - target_prim_path: The target prim path to obtain the prim view for. - - Returns: - A tuple containing: - - - An XFormPrim or a physics prim view (ArticulationView or RigidBodyView). - - A tuple containing the positions and orientations of the mesh prims in the physics prim frame. - - """ - - mesh_prim = sim_utils.find_first_matching_prim(target_prim_path) - current_prim = mesh_prim - current_path_expr = target_prim_path - - prim_view = None - - while prim_view is None: - if current_prim.HasAPI(UsdPhysics.ArticulationRootAPI): - prim_view = self._physics_sim_view.create_articulation_view(current_path_expr.replace(".*", "*")) - logger.info(f"Created articulation view for mesh prim at path: {target_prim_path}") - break - - if current_prim.HasAPI(UsdPhysics.RigidBodyAPI): - prim_view = self._physics_sim_view.create_rigid_body_view(current_path_expr.replace(".*", "*")) - logger.info(f"Created rigid body view for mesh prim at path: {target_prim_path}") - break - - new_root_prim = current_prim.GetParent() - current_path_expr = current_path_expr.rsplit("/", 1)[0] - if not new_root_prim.IsValid(): - prim_view = XformPrimView(target_prim_path, device=self._device, stage=self.stage) - current_path_expr = target_prim_path - logger.warning( - f"The prim at path {target_prim_path} which is used for raycasting is not a physics prim." - " Defaulting to XFormPrim. \n The pose of the mesh will most likely not" - " be updated correctly when running in headless mode and position lookups will be much slower. \n" - " If possible, ensure that the mesh or its parent is a physics prim (rigid body or articulation)." - ) - break - - current_prim = new_root_prim - - # obtain the relative transforms between target prim and the view prims - mesh_prims = sim_utils.find_matching_prims(target_prim_path) - view_prims = sim_utils.find_matching_prims(current_path_expr) - if len(mesh_prims) != len(view_prims): - raise RuntimeError( - f"The number of mesh prims ({len(mesh_prims)}) does not match the number of physics prims" - f" ({len(view_prims)})Please specify the correct mesh and physics prim paths more" - " specifically in your target expressions." - ) - positions = [] - quaternions = [] - for mesh_prim, view_prim in zip(mesh_prims, view_prims): - pos, orientation = sim_utils.resolve_prim_pose(mesh_prim, view_prim) - positions.append(torch.tensor(pos, dtype=torch.float32, device=self.device)) - quaternions.append(torch.tensor(orientation, dtype=torch.float32, device=self.device)) - - positions = torch.stack(positions).to(device=self.device, dtype=torch.float32) - quaternions = torch.stack(quaternions).to(device=self.device, dtype=torch.float32) - - return prim_view, (positions, quaternions) - """ Internal simulation callbacks. """ diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py index b9f53ea8c491..17bb1e601980 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_camera.py @@ -27,7 +27,6 @@ fill_vec3_inf_kernel, update_ray_caster_kernel, ) -from .ray_cast_utils import obtain_world_pose_from_view from .ray_caster import RayCaster if TYPE_CHECKING: @@ -168,9 +167,13 @@ def reset(self, env_ids: Sequence[int] | None = None, env_mask: wp.array | None env_ids = wp.to_torch(env_mask).nonzero(as_tuple=False).squeeze(-1) elif env_ids is None or isinstance(env_ids, slice): env_ids = self._ALL_INDICES + if not isinstance(env_ids, torch.Tensor): + env_ids = torch.tensor(env_ids, dtype=torch.long, device=self._device) # reset the data # note: this recomputation is useful if one performs events such as randomizations on the camera poses. - pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids, clone=True) + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None + pos_wp, quat_wp = self._view.get_world_poses(indices) + pos_w, quat_w = wp.to_torch(pos_wp).clone(), wp.to_torch(quat_wp).clone() pos_w, quat_w = math_utils.combine_frame_transforms( pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] ) @@ -214,7 +217,9 @@ def set_world_poses( env_ids = self._ALL_INDICES # get current positions - pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids) + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None + pos_wp, quat_wp = self._view.get_world_poses(indices) + pos_w, quat_w = wp.to_torch(pos_wp), wp.to_torch(quat_wp) if positions is not None: # transform to camera frame pos_offset_world_frame = positions - pos_w @@ -227,7 +232,8 @@ def set_world_poses( self._offset_quat[env_ids] = math_utils.quat_mul(math_utils.quat_inv(quat_w), quat_w_set) # update the data - pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids, clone=True) + pos_wp2, quat_wp2 = self._view.get_world_poses(indices) + pos_w, quat_w = wp.to_torch(pos_wp2).clone(), wp.to_torch(quat_wp2).clone() pos_w, quat_w = math_utils.combine_frame_transforms( pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] ) @@ -574,21 +580,22 @@ def _compute_view_world_poses(self, env_ids: Sequence[int]) -> tuple[torch.Tenso """Obtains the pose of the view the camera is attached to in the world frame. .. deprecated v2.3.1: - This function will be removed in a future release in favor of implementation - :meth:`obtain_world_pose_from_view`. + This function will be removed in a future release. Call + ``self._view.get_world_poses(indices)`` directly instead. Returns: A tuple of the position (in meters) and quaternion (x, y, z, w). """ - # deprecation logger.warning( - "The function '_compute_view_world_poses' will be deprecated in favor of the util method" - " 'obtain_world_pose_from_view'. Please use 'obtain_world_pose_from_view' instead...." + "The function '_compute_view_world_poses' is deprecated." + " Call 'self._view.get_world_poses(indices)' directly instead." ) - return obtain_world_pose_from_view(self._view, env_ids, clone=True) + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None + pos_wp, quat_wp = self._view.get_world_poses(indices) + return wp.to_torch(pos_wp).clone(), wp.to_torch(quat_wp).clone() def _compute_camera_world_poses(self, env_ids: Sequence[int]) -> tuple[torch.Tensor, torch.Tensor]: """Computes the pose of the camera in the world frame. @@ -600,7 +607,9 @@ def _compute_camera_world_poses(self, env_ids: Sequence[int]) -> tuple[torch.Ten .. code-block:: python - pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids, clone=True) + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) + pos_wp, quat_wp = self._view.get_world_poses(indices) + pos_w, quat_w = wp.to_torch(pos_wp).clone(), wp.to_torch(quat_wp).clone() pos_w, quat_w = math_utils.combine_frame_transforms( pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids] ) @@ -608,14 +617,12 @@ def _compute_camera_world_poses(self, env_ids: Sequence[int]) -> tuple[torch.Ten Returns: A tuple of the position (in meters) and quaternion (x, y, z, w) in "world" convention. """ - - # deprecation logger.warning( - "The function '_compute_camera_world_poses' will be deprecated in favor of the combination of methods" - " 'obtain_world_pose_from_view' and 'math_utils.combine_frame_transforms'. Please use" - " 'obtain_world_pose_from_view' and 'math_utils.combine_frame_transforms' instead...." + "The function '_compute_camera_world_poses' is deprecated." + " Call 'self._view.get_world_poses(indices)' and 'math_utils.combine_frame_transforms' directly instead." ) - # get the pose of the view the camera is attached to - pos_w, quat_w = obtain_world_pose_from_view(self._view, env_ids, clone=True) + indices = wp.from_torch(env_ids.to(dtype=torch.int32), dtype=wp.int32) if env_ids is not None else None + pos_wp, quat_wp = self._view.get_world_poses(indices) + pos_w, quat_w = wp.to_torch(pos_wp).clone(), wp.to_torch(quat_wp).clone() return math_utils.combine_frame_transforms(pos_w, quat_w, self._offset_pos[env_ids], self._offset_quat[env_ids]) diff --git a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py index 9c3ef14091c5..3e862e389c1e 100644 --- a/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py +++ b/source/isaaclab/isaaclab/sensors/ray_caster/ray_caster_cfg.py @@ -12,6 +12,7 @@ from isaaclab.markers import VisualizationMarkersCfg from isaaclab.markers.config import RAY_CASTER_MARKER_CFG +from isaaclab.sim.spawners.sensors.sensors_cfg import SensorFrameCfg from isaaclab.utils import configclass from ..sensor_base_cfg import SensorBaseCfg @@ -36,6 +37,21 @@ class OffsetCfg: class_type: type[RayCaster] | str = "{DIR}.ray_caster:RayCaster" + spawn: SensorFrameCfg | None = SensorFrameCfg() + """Spawn configuration for the sensor Xform prim. + + A plain USD Xform is created at :attr:`prim_path` before initialization, matching the + pattern used by :class:`~isaaclab.sensors.camera.camera_cfg.CameraCfg` (which spawns a + Camera prim). The :attr:`prim_path` can be either: + + - A **new** child path under a parent link (e.g. ``{ENV_REGEX_NS}/Robot/base``). + - A **physics body** path (e.g. ``{ENV_REGEX_NS}/Robot/base``). In this case, the sensor + will automatically create a child Xform at ``{prim_path}``. + + If ``None``, the prim at :attr:`prim_path` must already exist on the USD stage and must + **not** be a physics body. + """ + mesh_prim_paths: list[str] = MISSING """The list of mesh primitive paths to ray cast against. @@ -47,22 +63,6 @@ class OffsetCfg: offset: OffsetCfg = OffsetCfg() """The offset pose of the sensor's frame from the sensor's parent frame. Defaults to identity.""" - attach_yaw_only: bool | None = None - """Whether the rays' starting positions and directions only track the yaw orientation. - Defaults to None, which doesn't raise a warning of deprecated usage. - - This is useful for ray-casting height maps, where only yaw rotation is needed. - - .. deprecated:: 2.1.1 - - This attribute is deprecated and will be removed in the future. Please use - :attr:`ray_alignment` instead. - - To get the same behavior as setting this parameter to ``True`` or ``False``, set - :attr:`ray_alignment` to ``"yaw"`` or "base" respectively. - - """ - ray_alignment: Literal["base", "yaw", "world"] = "base" """Specify in what frame the rays are projected onto the ground. Default is "base". diff --git a/source/isaaclab/isaaclab/sensors/sensor_base.py b/source/isaaclab/isaaclab/sensors/sensor_base.py index 4a9fb91786e5..3b15d8a0171e 100644 --- a/source/isaaclab/isaaclab/sensors/sensor_base.py +++ b/source/isaaclab/isaaclab/sensors/sensor_base.py @@ -12,6 +12,7 @@ from __future__ import annotations import inspect +import logging import re import weakref from abc import ABC, abstractmethod @@ -29,6 +30,8 @@ if TYPE_CHECKING: from .sensor_base_cfg import SensorBaseCfg +logger = logging.getLogger(__name__) + class SensorBase(ABC): """The base class for implementing a sensor. @@ -386,3 +389,71 @@ def _resolve_indices_and_mask( self._reset_mask.zero_() self._reset_mask_torch[env_ids] = True return self._reset_mask + + def _resolve_and_spawn(self, sensor_name: str, **spawn_kwargs) -> None: + """Resolve physics-body prim paths and spawn the sensor prim if needed. + + Behavior matrix (``spawn`` refers to ``cfg.spawn``): + + +----------------+------------------+--------------------------------------------+ + | ``spawn`` | ``prim_path`` | Action | + +================+==================+============================================+ + | not ``None`` | physics body | Append ``/``, spawn child. | + +----------------+------------------+--------------------------------------------+ + | not ``None`` | non-physics prim | Use existing prim, skip spawn. | + | | (already exists) | | + +----------------+------------------+--------------------------------------------+ + | not ``None`` | does not exist | Spawn prim at ``prim_path``. | + +----------------+------------------+--------------------------------------------+ + | ``None`` | physics body | Raise ``ValueError``. | + +----------------+------------------+--------------------------------------------+ + | ``None`` | non-physics prim | Use as-is (no spawn). | + +----------------+------------------+--------------------------------------------+ + + Args: + sensor_name: Short identifier (e.g. ``"raycaster"``, ``"camera"``). + **spawn_kwargs: Extra keyword arguments forwarded to ``cfg.spawn.func`` + (e.g. ``translation``, ``orientation``). + + Raises: + ValueError: If ``spawn`` is ``None`` and ``prim_path`` is a physics body. + RuntimeError: If the prim does not exist after the spawn attempt. + """ + from pxr import UsdPhysics # noqa: PLC0415 + + spawn = getattr(self.cfg, "spawn", None) + has_spawn = spawn is not None + + # Determine the path to probe for physics-body redirect + spawn_path = (getattr(spawn, "spawn_path", None) or self.cfg.prim_path) if has_spawn else None + probe_path = spawn_path if spawn_path is not None else self.cfg.prim_path + + prim = sim_utils.find_first_matching_prim(probe_path) + if prim is not None and prim.IsValid(): + is_physics = prim.HasAPI(UsdPhysics.ArticulationRootAPI) or prim.HasAPI(UsdPhysics.RigidBodyAPI) + if is_physics: + if not has_spawn: + raise ValueError( + f"Sensor prim_path '{self.cfg.prim_path}' resolves to a physics body but" + f" no spawner is configured (spawn=None). Either set spawn or point" + f" prim_path at a non-physics child (e.g. '{self.cfg.prim_path}/{sensor_name}')." + ) + logger.info( + f"Sensor prim_path '{self.cfg.prim_path}' points at a physics body." + f" Redirecting to '{self.cfg.prim_path}/{sensor_name}'." + ) + self.cfg.prim_path = f"{self.cfg.prim_path}/{sensor_name}" + if getattr(spawn, "spawn_path", None) is not None: + spawn.spawn_path = f"{spawn.spawn_path}/{sensor_name}" + + if not has_spawn: + return + + spawn_target = getattr(spawn, "spawn_path", None) or self.cfg.prim_path + prim = sim_utils.find_first_matching_prim(spawn_target) + if prim is None or not prim.IsValid(): + spawn.func(spawn_target, spawn, **spawn_kwargs) + + check_path = getattr(spawn, "spawn_path", None) or self.cfg.prim_path + if len(sim_utils.find_matching_prims(check_path)) == 0: + raise RuntimeError(f"Could not find prim with path {check_path!r}.") diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index aa1816845242..a718ccdcb989 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -90,8 +90,10 @@ __all__ = [ "MeshSphereCfg", "MeshSquareCfg", "spawn_camera", + "spawn_sensor_frame", "FisheyeCameraCfg", "PinholeCameraCfg", + "SensorFrameCfg", "spawn_capsule", "spawn_cone", "spawn_cuboid", @@ -160,6 +162,10 @@ __all__ = [ "resolve_prim_pose", "resolve_prim_scale", "convert_world_pose_to_local", + "BaseFrameView", + "UsdFrameView", + "FrameView", + # Deprecated alias "XformPrimView", ] @@ -252,8 +258,10 @@ from .spawners import ( MeshSphereCfg, MeshSquareCfg, spawn_camera, + spawn_sensor_frame, FisheyeCameraCfg, PinholeCameraCfg, + SensorFrameCfg, spawn_capsule, spawn_cone, spawn_cuboid, @@ -325,4 +333,5 @@ from .utils import ( resolve_prim_scale, convert_world_pose_to_local, ) -from .views import XformPrimView +from .views import BaseFrameView, UsdFrameView, FrameView +from .views import XformPrimView # deprecated alias diff --git a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi index ba8f6d3d7b69..dae1a432b47e 100644 --- a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi @@ -46,8 +46,10 @@ __all__ = [ "MeshSphereCfg", "MeshSquareCfg", "spawn_camera", + "spawn_sensor_frame", "FisheyeCameraCfg", "PinholeCameraCfg", + "SensorFrameCfg", "spawn_capsule", "spawn_cone", "spawn_cuboid", @@ -113,7 +115,7 @@ from .meshes import ( MeshSquareCfg, MeshSphereCfg, ) -from .sensors import spawn_camera, FisheyeCameraCfg, PinholeCameraCfg +from .sensors import spawn_camera, spawn_sensor_frame, FisheyeCameraCfg, PinholeCameraCfg, SensorFrameCfg from .shapes import ( spawn_capsule, spawn_cone, diff --git a/source/isaaclab/isaaclab/sim/spawners/sensors/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/sensors/__init__.pyi index 46d24596932c..ba5b96a44c7d 100644 --- a/source/isaaclab/isaaclab/sim/spawners/sensors/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/sensors/__init__.pyi @@ -5,9 +5,11 @@ __all__ = [ "spawn_camera", + "spawn_sensor_frame", "FisheyeCameraCfg", "PinholeCameraCfg", + "SensorFrameCfg", ] -from .sensors import spawn_camera -from .sensors_cfg import FisheyeCameraCfg, PinholeCameraCfg +from .sensors import spawn_camera, spawn_sensor_frame +from .sensors_cfg import FisheyeCameraCfg, PinholeCameraCfg, SensorFrameCfg diff --git a/source/isaaclab/isaaclab/sim/spawners/sensors/sensors.py b/source/isaaclab/isaaclab/sim/spawners/sensors/sensors.py index 4eb70005e487..db68d21d8a90 100644 --- a/source/isaaclab/isaaclab/sim/spawners/sensors/sensors.py +++ b/source/isaaclab/isaaclab/sim/spawners/sensors/sensors.py @@ -145,3 +145,46 @@ def spawn_camera( prim.GetAttribute(prim_prop_name).Set(param_value) # return the prim return prim + + +@clone +def spawn_sensor_frame( + prim_path: str, + cfg: sensors_cfg.SensorFrameCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + **kwargs, +) -> Usd.Prim: + """Create a plain USD Xform prim as a sensor attachment frame. + + .. note:: + This function is decorated with :func:`clone` that resolves prim path into list of paths + if the input prim path is a regex pattern. + + Args: + prim_path: The prim path or pattern to spawn the asset at. + cfg: The configuration instance. + translation: Local translation (x, y, z) [m] w.r.t. the parent prim. Defaults to None + (origin). + orientation: Local orientation as quaternion (x, y, z, w) w.r.t. the parent prim. + Defaults to None (identity). + **kwargs: Additional keyword arguments, like ``clone_in_fabric``. + + Returns: + The created USD prim. + + Raises: + ValueError: If a prim already exists at the given path. + """ + stage = get_current_stage() + if not stage.GetPrimAtPath(prim_path).IsValid(): + prim = create_prim( + prim_path, + "Xform", + translation=translation, + orientation=orientation, + stage=stage, + ) + else: + raise ValueError(f"A prim already exists at path: '{prim_path}'.") + return prim diff --git a/source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py b/source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py index 56c9102cf1f1..1ad9dcd73bf2 100644 --- a/source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/sensors/sensors_cfg.py @@ -222,3 +222,15 @@ class FisheyeCameraCfg(PinholeCameraCfg): fisheye_polynomial_f: float = 0.0 """Sixth component of fisheye polynomial. Defaults to 0.0.""" + + +@configclass +class SensorFrameCfg(SpawnerCfg): + """Spawns a plain USD Xform as a sensor attachment frame. + + The spawned prim carries no rigid body or collision API. It serves as a + non-physics child under a link so that :class:`~isaaclab.sim.views.FrameView` + can track it on all backends (including Newton, which rejects physics body prims). + """ + + func: Callable | str = "{DIR}.sensors:spawn_sensor_frame" diff --git a/source/isaaclab/isaaclab/sim/views/__init__.pyi b/source/isaaclab/isaaclab/sim/views/__init__.pyi index a666958e4387..d578f85d6ada 100644 --- a/source/isaaclab/isaaclab/sim/views/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/views/__init__.pyi @@ -4,7 +4,15 @@ # SPDX-License-Identifier: BSD-3-Clause __all__ = [ + "BaseFrameView", + "UsdFrameView", + "FrameView", + # Deprecated alias "XformPrimView", ] +from .base_frame_view import BaseFrameView +from .usd_frame_view import UsdFrameView +from .frame_view import FrameView +# Deprecated alias from .xform_prim_view import XformPrimView diff --git a/source/isaaclab/isaaclab/sim/views/base_frame_view.py b/source/isaaclab/isaaclab/sim/views/base_frame_view.py new file mode 100644 index 000000000000..fc59c2ed83ab --- /dev/null +++ b/source/isaaclab/isaaclab/sim/views/base_frame_view.py @@ -0,0 +1,108 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Abstract base class for batched prim transform views.""" + +from __future__ import annotations + +import abc + +import warp as wp + + +class BaseFrameView(abc.ABC): + """Abstract interface for reading and writing world-space transforms of multiple prims. + + Backend-specific implementations (USD/Fabric, Newton GPU state, etc.) subclass + this to provide efficient batched pose queries. The factory + :class:`~isaaclab.sim.views.FrameView` selects the correct + implementation at runtime based on the active physics backend. + + All getters return ``wp.array``. Setters accept ``wp.array``. + """ + + @property + @abc.abstractmethod + def count(self) -> int: + """Number of prims in this view.""" + ... + + @abc.abstractmethod + def get_world_poses(self, indices: wp.array | None = None) -> tuple[wp.array, wp.array]: + """Get world-space positions and orientations for prims in the view. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A tuple ``(positions (M, 3), orientations (M, 4))`` as ``wp.array``. + """ + ... + + @abc.abstractmethod + def set_world_poses( + self, + positions: wp.array | None = None, + orientations: wp.array | None = None, + indices: wp.array | None = None, + ) -> None: + """Set world-space positions and/or orientations for prims in the view. + + Args: + positions: World-space positions ``(M, 3)``. ``None`` leaves positions unchanged. + orientations: World-space quaternions ``(M, 4)``. ``None`` leaves orientations unchanged. + indices: Subset of prims to update. ``None`` means all prims. + """ + ... + + @abc.abstractmethod + def get_local_poses(self, indices: wp.array | None = None) -> tuple[wp.array, wp.array]: + """Get local-space positions and orientations for prims in the view. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A tuple ``(translations (M, 3), orientations (M, 4))`` as ``wp.array``. + """ + ... + + @abc.abstractmethod + def set_local_poses( + self, + translations: wp.array | None = None, + orientations: wp.array | None = None, + indices: wp.array | None = None, + ) -> None: + """Set local-space translations and/or orientations for prims in the view. + + Args: + translations: Local-space translations ``(M, 3)``. ``None`` leaves translations unchanged. + orientations: Local-space quaternions ``(M, 4)``. ``None`` leaves orientations unchanged. + indices: Subset of prims to update. ``None`` means all prims. + """ + ... + + @abc.abstractmethod + def get_scales(self, indices: wp.array | None = None) -> wp.array: + """Get scales for prims in the view. + + Args: + indices: Subset of prims to query. ``None`` means all prims. + + Returns: + A ``wp.array`` of shape ``(M, 3)``. + """ + ... + + @abc.abstractmethod + def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set scales for prims in the view. + + Args: + scales: Scales ``(M, 3)`` as ``wp.array``. + indices: Subset of prims to update. ``None`` means all prims. + """ + ... diff --git a/source/isaaclab/isaaclab/sim/views/frame_view.py b/source/isaaclab/isaaclab/sim/views/frame_view.py new file mode 100644 index 000000000000..ea9d5bfbeea9 --- /dev/null +++ b/source/isaaclab/isaaclab/sim/views/frame_view.py @@ -0,0 +1,48 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Backend-dispatching FrameView. + +``FrameView(path, device=...)`` automatically selects the right backend: +- PhysX: :class:`~isaaclab_physx.sim.views.FabricFrameView` +- Newton: :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` +""" + +from __future__ import annotations + +from isaaclab.utils.backend_utils import FactoryBase + +from .base_frame_view import BaseFrameView + + +class FrameView(FactoryBase, BaseFrameView): + """FrameView that dispatches to the active physics backend. + + Callers use ``FrameView(prim_path, device=device)`` and get the + correct implementation automatically: + + - **PhysX / no backend**: :class:`~isaaclab_physx.sim.views.FabricFrameView` + (Fabric GPU acceleration with USD fallback). + - **Newton**: :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView` + (GPU-resident site-based transforms). + """ + + _backend_class_names = {"physx": "FabricFrameView", "newton": "NewtonSiteFrameView"} + + @classmethod + def _get_backend(cls, *args, **kwargs) -> str: + from isaaclab.sim.simulation_context import SimulationContext # noqa: PLC0415 + + ctx = SimulationContext.instance() + if ctx is None: + return "physx" + manager_name = ctx.physics_manager.__name__.lower() + if "newton" in manager_name: + return "newton" + return "physx" + + def __new__(cls, *args, **kwargs) -> BaseFrameView: + """Create a new FrameView for the active physics backend.""" + return super().__new__(cls, *args, **kwargs) diff --git a/source/isaaclab/isaaclab/sim/views/usd_frame_view.py b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py new file mode 100644 index 000000000000..4421fa5391ea --- /dev/null +++ b/source/isaaclab/isaaclab/sim/views/usd_frame_view.py @@ -0,0 +1,359 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import logging + +import numpy as np +import torch +import warp as wp + +from pxr import Gf, Sdf, Usd, UsdGeom, Vt + +import isaaclab.sim as sim_utils + +from .base_frame_view import BaseFrameView + +logger = logging.getLogger(__name__) + + +class UsdFrameView(BaseFrameView): + """Batched interface for reading and writing transforms of multiple USD prims. + + Provides batch operations for getting and setting poses (position and orientation) + of multiple prims at once via USD's ``XformCache``. + + The class supports both world-space and local-space pose operations: + + - **World poses**: Positions and orientations in the global world frame + - **Local poses**: Positions and orientations relative to each prim's parent + + For GPU-accelerated Fabric operations, use the PhysX backend variant + obtained via :class:`~isaaclab.sim.views.FrameView`. + + All getters return ``wp.array``. Setters accept ``wp.array``. + + .. note:: + **Transform Requirements:** + + All prims in the view must be Xformable and have standardized transform operations: + ``[translate, orient, scale]``. Non-standard prims will raise a ValueError during + initialization if :attr:`validate_xform_ops` is True. Please use the function + :func:`isaaclab.sim.utils.standardize_xform_ops` to prepare prims before using this view. + + .. warning:: + This class operates at the USD default time code. Any animation or time-sampled data + will not be affected by write operations. For animated transforms, you need to handle + time-sampled keyframes separately. + """ + + def __init__( + self, + prim_path: str, + device: str = "cpu", + validate_xform_ops: bool = True, + stage: Usd.Stage | None = None, + **kwargs, + ): + """Initialize the view with matching prims. + + Args: + prim_path: USD prim path pattern to match prims. Supports wildcards (``*``) and + regex patterns (e.g., ``"/World/Env_.*/Robot"``). See + :func:`isaaclab.sim.utils.find_matching_prims` for pattern syntax. + device: Device to place arrays on. Can be ``"cpu"`` or CUDA devices like + ``"cuda:0"``. Defaults to ``"cpu"``. + validate_xform_ops: Whether to validate that the prims have standard xform operations. + Defaults to True. + stage: USD stage to search for prims. Defaults to None, in which case the current + active stage from the simulation context is used. + **kwargs: Additional keyword arguments (ignored). Allows forward-compatible + construction when callers pass backend-specific options like + ``sync_usd_on_fabric_write``. + + Raises: + ValueError: If any matched prim is not Xformable or doesn't have standardized + transform operations (translate, orient, scale in that order). + """ + self._prim_path = prim_path + self._device = device + + stage = sim_utils.get_current_stage() if stage is None else stage + self._prims: list[Usd.Prim] = sim_utils.find_matching_prims(prim_path, stage=stage) + + if validate_xform_ops: + for prim in self._prims: + sim_utils.standardize_xform_ops(prim) + if not sim_utils.validate_standard_xform_ops(prim): + raise ValueError( + f"Prim at path '{prim.GetPath().pathString}' is not a xformable prim with standard transform" + f" operations [translate, orient, scale]. Received type: '{prim.GetTypeName()}'." + " Use sim_utils.standardize_xform_ops() to prepare the prim." + ) + + self._ALL_INDICES = list(range(len(self._prims))) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def count(self) -> int: + """Number of prims in this view.""" + return len(self._prims) + + @property + def device(self) -> str: + """Device where arrays are allocated (cpu or cuda).""" + return self._device + + @property + def prims(self) -> list[Usd.Prim]: + """List of USD prims being managed by this view.""" + return self._prims + + @property + def prim_paths(self) -> list[str]: + """List of prim paths (as strings) for all prims being managed by this view. + + The conversion is performed lazily on first access and cached. + """ + if not hasattr(self, "_prim_paths"): + self._prim_paths = [prim.GetPath().pathString for prim in self._prims] + return self._prim_paths + + # ------------------------------------------------------------------ + # Setters + # ------------------------------------------------------------------ + + def set_world_poses( + self, + positions: wp.array | None = None, + orientations: wp.array | None = None, + indices: wp.array | None = None, + ): + """Set world-space poses for prims in the view. + + Converts the desired world pose to local-space relative to each prim's + parent before writing to USD xform ops. + + Args: + positions: World-space positions of shape ``(M, 3)``. + orientations: World-space quaternions ``(w, x, y, z)`` of shape ``(M, 4)``. + indices: Indices of prims to set poses for. Defaults to None (all prims). + """ + indices_list = self._resolve_indices(indices) + + positions_array = Vt.Vec3dArray.FromNumpy(self._to_numpy(positions)) if positions is not None else None + orientations_array = Vt.QuatdArray.FromNumpy(self._to_numpy(orientations)) if orientations is not None else None + + xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + + with Sdf.ChangeBlock(): + for idx, prim_idx in enumerate(indices_list): + prim = self._prims[prim_idx] + parent_prim = prim.GetParent() + + world_pos = positions_array[idx] if positions_array is not None else None + world_quat = orientations_array[idx] if orientations_array is not None else None + + if parent_prim.IsValid() and parent_prim.GetPath() != Sdf.Path.absoluteRootPath: + if positions_array is None or orientations_array is None: + prim_tf = xform_cache.GetLocalToWorldTransform(prim) + prim_tf.Orthonormalize() + if world_pos is not None: + prim_tf.SetTranslateOnly(world_pos) + if world_quat is not None: + prim_tf.SetRotateOnly(world_quat) + else: + prim_tf = Gf.Matrix4d() + prim_tf.SetTranslateOnly(world_pos) + prim_tf.SetRotateOnly(world_quat) + + parent_world_tf = xform_cache.GetLocalToWorldTransform(parent_prim) + local_tf = prim_tf * parent_world_tf.GetInverse() + local_pos = local_tf.ExtractTranslation() + local_quat = local_tf.ExtractRotationQuat() + else: + # Root-level prim: world == local + local_pos = world_pos + local_quat = world_quat + + if local_pos is not None: + prim.GetAttribute("xformOp:translate").Set(local_pos) + if local_quat is not None: + prim.GetAttribute("xformOp:orient").Set(local_quat) + + def set_local_poses( + self, + translations: wp.array | None = None, + orientations: wp.array | None = None, + indices: wp.array | None = None, + ): + """Set local-space poses for prims in the view. + + Args: + translations: Local-space translations of shape ``(M, 3)``. + orientations: Local-space quaternions ``(w, x, y, z)`` of shape ``(M, 4)``. + indices: Indices of prims to set poses for. Defaults to None (all prims). + """ + indices_list = self._resolve_indices(indices) + + translations_array = Vt.Vec3dArray.FromNumpy(self._to_numpy(translations)) if translations is not None else None + orientations_array = Vt.QuatdArray.FromNumpy(self._to_numpy(orientations)) if orientations is not None else None + + with Sdf.ChangeBlock(): + for idx, prim_idx in enumerate(indices_list): + prim = self._prims[prim_idx] + if translations_array is not None: + prim.GetAttribute("xformOp:translate").Set(translations_array[idx]) + if orientations_array is not None: + prim.GetAttribute("xformOp:orient").Set(orientations_array[idx]) + + def set_scales(self, scales: wp.array, indices: wp.array | None = None): + """Set scales for prims in the view. + + Args: + scales: Scales of shape ``(M, 3)``. + indices: Indices of prims to set scales for. Defaults to None (all prims). + """ + indices_list = self._resolve_indices(indices) + scales_array = Vt.Vec3dArray.FromNumpy(self._to_numpy(scales)) + + with Sdf.ChangeBlock(): + for idx, prim_idx in enumerate(indices_list): + prim = self._prims[prim_idx] + prim.GetAttribute("xformOp:scale").Set(scales_array[idx]) + + def set_visibility(self, visibility: torch.Tensor, indices: wp.array | None = None): + """Set visibility for prims in the view. + + Args: + visibility: Visibility as a boolean tensor of shape ``(M,)``. + indices: Indices of prims to set visibility for. Defaults to None (all prims). + """ + indices_list = self._resolve_indices(indices) + + if visibility.shape != (len(indices_list),): + raise ValueError(f"Expected visibility shape ({len(indices_list)},), got {visibility.shape}.") + + with Sdf.ChangeBlock(): + for idx, prim_idx in enumerate(indices_list): + imageable = UsdGeom.Imageable(self._prims[prim_idx]) + if visibility[idx]: + imageable.MakeVisible() + else: + imageable.MakeInvisible() + + # ------------------------------------------------------------------ + # Getters + # ------------------------------------------------------------------ + + def get_world_poses(self, indices: wp.array | None = None) -> tuple[wp.array, wp.array]: + """Get world-space poses for prims in the view. + + Args: + indices: Indices of prims to get poses for. Defaults to None (all prims). + + Returns: + A tuple of ``(positions, orientations)`` as ``wp.array``. + """ + indices_list = self._resolve_indices(indices) + + positions = Vt.Vec3dArray(len(indices_list)) + orientations = Vt.QuatdArray(len(indices_list)) + xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + + for idx, prim_idx in enumerate(indices_list): + prim = self._prims[prim_idx] + prim_tf = xform_cache.GetLocalToWorldTransform(prim) + prim_tf.Orthonormalize() + positions[idx] = prim_tf.ExtractTranslation() + orientations[idx] = prim_tf.ExtractRotationQuat() + + return ( + wp.array(np.array(positions, dtype=np.float32), dtype=wp.float32, device=self._device), + wp.array(np.array(orientations, dtype=np.float32), dtype=wp.float32, device=self._device), + ) + + def get_local_poses(self, indices: wp.array | None = None) -> tuple[wp.array, wp.array]: + """Get local-space poses for prims in the view. + + Args: + indices: Indices of prims to get poses for. Defaults to None (all prims). + + Returns: + A tuple of ``(translations, orientations)`` as ``wp.array``. + """ + indices_list = self._resolve_indices(indices) + + translations = Vt.Vec3dArray(len(indices_list)) + orientations = Vt.QuatdArray(len(indices_list)) + xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + + for idx, prim_idx in enumerate(indices_list): + prim = self._prims[prim_idx] + prim_tf = xform_cache.GetLocalTransformation(prim)[0] + prim_tf.Orthonormalize() + translations[idx] = prim_tf.ExtractTranslation() + orientations[idx] = prim_tf.ExtractRotationQuat() + + return ( + wp.array(np.array(translations, dtype=np.float32), dtype=wp.float32, device=self._device), + wp.array(np.array(orientations, dtype=np.float32), dtype=wp.float32, device=self._device), + ) + + def get_scales(self, indices: wp.array | None = None) -> wp.array: + """Get scales for prims in the view. + + Args: + indices: Indices of prims to get scales for. Defaults to None (all prims). + + Returns: + A ``wp.array`` of shape ``(M, 3)``. + """ + indices_list = self._resolve_indices(indices) + + scales = Vt.Vec3dArray(len(indices_list)) + for idx, prim_idx in enumerate(indices_list): + prim = self._prims[prim_idx] + scales[idx] = prim.GetAttribute("xformOp:scale").Get() + + return wp.array(np.array(scales, dtype=np.float32), dtype=wp.float32, device=self._device) + + def get_visibility(self, indices: wp.array | None = None) -> torch.Tensor: + """Get visibility for prims in the view. + + Args: + indices: Indices of prims to get visibility for. Defaults to None (all prims). + + Returns: + A tensor of shape ``(M,)`` containing the visibility of each prim (bool). + """ + indices_list = self._resolve_indices(indices) + + visibility = torch.zeros(len(indices_list), dtype=torch.bool, device=self._device) + for idx, prim_idx in enumerate(indices_list): + imageable = UsdGeom.Imageable(self._prims[prim_idx]) + visibility[idx] = imageable.ComputeVisibility() != UsdGeom.Tokens.invisible + return visibility + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _resolve_indices(self, indices: wp.array | None): + """Resolve warp indices to an iterable of ints for per-prim USD operations.""" + if indices is None or indices == slice(None): + return self._ALL_INDICES + return indices.numpy() + + @staticmethod + def _to_numpy(data: wp.array | torch.Tensor) -> np.ndarray: + """Convert a ``wp.array`` or ``torch.Tensor`` to a numpy array on CPU.""" + if isinstance(data, wp.array): + return data.numpy() + return data.cpu().numpy() diff --git a/source/isaaclab/isaaclab/sim/views/xform_prim_view.py b/source/isaaclab/isaaclab/sim/views/xform_prim_view.py index 211994a7226b..ce480fa65594 100644 --- a/source/isaaclab/isaaclab/sim/views/xform_prim_view.py +++ b/source/isaaclab/isaaclab/sim/views/xform_prim_view.py @@ -3,1138 +3,8 @@ # # SPDX-License-Identifier: BSD-3-Clause -from __future__ import annotations +"""Backward-compatibility alias: ``XformPrimView`` -> :class:`FrameView`.""" -import logging -from collections.abc import Sequence +from .frame_view import FrameView -import numpy as np -import torch -import warp as wp - -from pxr import Gf, Sdf, Usd, UsdGeom, Vt - -import isaaclab.sim as sim_utils -from isaaclab.app.settings_manager import SettingsManager -from isaaclab.utils.warp import fabric as fabric_utils - -logger = logging.getLogger(__name__) - - -class XformPrimView: - """Optimized batched interface for reading and writing transforms of multiple USD prims. - - This class provides efficient batch operations for getting and setting poses (position and orientation) - of multiple prims at once using torch tensors. It is designed for scenarios where you need to manipulate - many prims simultaneously, such as in multi-agent simulations or large-scale procedural generation. - - The class supports both world-space and local-space pose operations: - - - **World poses**: Positions and orientations in the global world frame - - **Local poses**: Positions and orientations relative to each prim's parent - - When Fabric is enabled, the class leverages NVIDIA's Fabric API for GPU-accelerated batch operations: - - - Uses `omni:fabric:worldMatrix` and `omni:fabric:localMatrix` attributes for all Boundable prims - - Performs batch matrix decomposition/composition using Warp kernels on GPU - - Achieves performance comparable to Isaac Sim's XFormPrim implementation - - Works for both physics-enabled and non-physics prims (cameras, meshes, etc.). - Note: renderers typically consume USD-authored camera transforms. - - .. warning:: - **Fabric requires CUDA**: Fabric is only supported with on CUDA devices. - Warp's CPU backend for fabric-array writes has known issues, so attempting to use - Fabric with CPU device (``device="cpu"``) will raise a ValueError at initialization. - - .. note:: - **Fabric Support:** - - When Fabric is enabled, this view ensures prims have the required Fabric hierarchy - attributes (``omni:fabric:localMatrix`` and ``omni:fabric:worldMatrix``). On first Fabric - read, USD-authored transforms initialize Fabric state. Fabric writes can optionally - be mirrored back to USD via :attr:`sync_usd_on_fabric_write`. - - For more information, see the `Fabric Hierarchy documentation`_. - - .. _Fabric Hierarchy documentation: https://docs.omniverse.nvidia.com/kit/docs/usdrt/latest/docs/fabric_hierarchy.html - - .. note:: - **Performance Considerations:** - - * Tensor operations are performed on the specified device (CPU/CUDA) - * USD write operations use ``Sdf.ChangeBlock`` for batched updates - * Fabric operations use GPU-accelerated Warp kernels for maximum performance - * For maximum performance, minimize get/set operations within tight loops - - .. note:: - **Transform Requirements:** - - All prims in the view must be Xformable and have standardized transform operations: - ``[translate, orient, scale]``. Non-standard prims will raise a ValueError during - initialization if :attr:`validate_xform_ops` is True. Please use the function - :func:`isaaclab.sim.utils.standardize_xform_ops` to prepare prims before using this view. - - .. warning:: - This class operates at the USD default time code. Any animation or time-sampled data - will not be affected by write operations. For animated transforms, you need to handle - time-sampled keyframes separately. - """ - - def __init__( - self, - prim_path: str, - device: str = "cpu", - validate_xform_ops: bool = True, - sync_usd_on_fabric_write: bool = False, - stage: Usd.Stage | None = None, - ): - """Initialize the view with matching prims. - - This method searches the USD stage for all prims matching the provided path pattern, - validates that they are Xformable with standard transform operations, and stores - references for efficient batch operations. - - We generally recommend to validate the xform operations, as it ensures that the prims are in a consistent state - and have the standard transform operations (translate, orient, scale in that order). - However, if you are sure that the prims are in a consistent state, you can set this to False to improve - performance. This can save around 45-50% of the time taken to initialize the view. - - Args: - prim_path: USD prim path pattern to match prims. Supports wildcards (``*``) and - regex patterns (e.g., ``"/World/Env_.*/Robot"``). See - :func:`isaaclab.sim.utils.find_matching_prims` for pattern syntax. - device: Device to place the tensors on. Can be ``"cpu"`` or CUDA devices like - ``"cuda:0"``. Defaults to ``"cpu"``. - validate_xform_ops: Whether to validate that the prims have standard xform operations. - Defaults to True. - sync_usd_on_fabric_write: Whether to mirror Fabric transform writes back to USD. - When True, transform updates are synchronized to USD so that USD data readers (e.g., rendering - cameras) can observe these changes. Defaults to False for better performance. - stage: USD stage to search for prims. Defaults to None, in which case the current active stage - from the simulation context is used. - - Raises: - ValueError: If any matched prim is not Xformable or doesn't have standardized - transform operations (translate, orient, scale in that order). - """ - # Store configuration - self._prim_path = prim_path - self._device = device - - # Find and validate matching prims - stage = sim_utils.get_current_stage() if stage is None else stage - self._prims: list[Usd.Prim] = sim_utils.find_matching_prims(prim_path, stage=stage) - - # Validate all prims have standard xform operations - if validate_xform_ops: - for prim in self._prims: - sim_utils.standardize_xform_ops(prim) - if not sim_utils.validate_standard_xform_ops(prim): - raise ValueError( - f"Prim at path '{prim.GetPath().pathString}' is not a xformable prim with standard transform" - f" operations [translate, orient, scale]. Received type: '{prim.GetTypeName()}'." - " Use sim_utils.standardize_xform_ops() to prepare the prim." - ) - - # Determine if Fabric is supported on the device - settings = SettingsManager.instance() - self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) - - # Check for unsupported Fabric + CPU combination - if self._use_fabric and self._device == "cpu": - logger.warning( - "Fabric mode with Warp fabric-array operations is not supported on CPU devices. " - "While Fabric itself can run on both CPU and GPU, our batch Warp kernels for " - "fabric-array operations require CUDA and are not reliable on the CPU backend. " - "To ensure stability, Fabric is being disabled and execution will fall back " - "to standard USD operations on the CPU. This may impact performance." - ) - self._use_fabric = False - - # Check for unsupported Fabric + non-primary CUDA device combination. - # USDRT SelectPrims and Warp fabric arrays only support cuda:0 internally. - # When running on cuda:1 or higher, SelectPrims raises a C++ error regardless of - # the device argument, because USDRT uses the active CUDA context (which is cuda:1). - if self._use_fabric and self._device not in ("cuda", "cuda:0"): - logger.warning( - f"Fabric mode is not supported on device '{self._device}'. " - "USDRT SelectPrims and Warp fabric arrays only support cuda:0. " - "Falling back to standard USD operations. This may impact performance." - ) - self._use_fabric = False - - # Create indices buffer - # Since we iterate over the indices, we need to use range instead of torch tensor - self._ALL_INDICES = list(range(len(self._prims))) - - # Some prims (e.g., Cameras) require USD-authored transforms for rendering. - # When enabled, mirror Fabric pose writes to USD for those prims. - self._sync_usd_on_fabric_write = sync_usd_on_fabric_write - - # Fabric batch infrastructure (initialized lazily on first use) - self._fabric_initialized = False - self._fabric_usd_sync_done = False - self._fabric_selection = None - self._fabric_to_view: wp.array | None = None - self._view_to_fabric: wp.array | None = None - self._default_view_indices: wp.array | None = None - self._fabric_hierarchy = None - # Create a valid USD attribute name: namespace:name - # Use "isaaclab" namespace to identify our custom attributes - self._view_index_attr = f"isaaclab:view_index:{abs(hash(self))}" - - """ - Properties. - """ - - @property - def count(self) -> int: - """Number of prims in this view.""" - return len(self._prims) - - @property - def device(self) -> str: - """Device where tensors are allocated (cpu or cuda).""" - return self._device - - @property - def prims(self) -> list[Usd.Prim]: - """List of USD prims being managed by this view.""" - return self._prims - - @property - def prim_paths(self) -> list[str]: - """List of prim paths (as strings) for all prims being managed by this view. - - This property converts each prim to its path string representation. The conversion is - performed lazily on first access and cached for subsequent accesses. - - Note: - For most use cases, prefer using :attr:`prims` directly as it provides direct access - to the USD prim objects without the conversion overhead. This property is mainly useful - for logging, debugging, or when string paths are explicitly required. - """ - # we cache it the first time it is accessed. - # we don't compute it in constructor because it is expensive and we don't need it most of the time. - # users should usually deal with prims directly as they typically need to access the prims directly. - if not hasattr(self, "_prim_paths"): - self._prim_paths = [prim.GetPath().pathString for prim in self._prims] - return self._prim_paths - - """ - Operations - Setters. - """ - - def set_world_poses( - self, - positions: torch.Tensor | None = None, - orientations: torch.Tensor | None = None, - indices: Sequence[int] | None = None, - ): - """Set world-space poses for prims in the view. - - This method sets the position and/or orientation of each prim in world space. - - - When Fabric is enabled, the function writes directly to Fabric's ``omni:fabric:worldMatrix`` - attribute using GPU-accelerated batch operations. - - When Fabric is disabled, the function converts to local space and writes to USD's ``xformOp:translate`` - and ``xformOp:orient`` attributes. - - Args: - positions: World-space positions as a tensor of shape (M, 3) where M is the number of prims - to set (either all prims if indices is None, or the number of indices provided). - Defaults to None, in which case positions are not modified. - orientations: World-space orientations as quaternions (w, x, y, z) with shape (M, 4). - Defaults to None, in which case orientations are not modified. - indices: Indices of prims to set poses for. Defaults to None, in which case poses are set - for all prims in the view. - - Raises: - ValueError: If positions shape is not (M, 3) or orientations shape is not (M, 4). - ValueError: If the number of poses doesn't match the number of indices provided. - """ - if self._use_fabric: - self._set_world_poses_fabric(positions, orientations, indices) - else: - self._set_world_poses_usd(positions, orientations, indices) - - def set_local_poses( - self, - translations: torch.Tensor | None = None, - orientations: torch.Tensor | None = None, - indices: Sequence[int] | None = None, - ): - """Set local-space poses for prims in the view. - - This method sets the position and/or orientation of each prim in local space (relative to - their parent prims). - - The function writes directly to USD's ``xformOp:translate`` and ``xformOp:orient`` attributes. - - Note: - Even in Fabric mode, local pose operations use USD. This behavior is based on Isaac Sim's design - where Fabric is only used for world pose operations. - - Rationale: - - Local pose writes need correct parent-child hierarchy relationships - - USD maintains these relationships correctly and efficiently - - Fabric is optimized for world pose operations, not local hierarchies - - Args: - translations: Local-space translations as a tensor of shape (M, 3) where M is the number of prims - to set (either all prims if indices is None, or the number of indices provided). - Defaults to None, in which case translations are not modified. - orientations: Local-space orientations as quaternions (w, x, y, z) with shape (M, 4). - Defaults to None, in which case orientations are not modified. - indices: Indices of prims to set poses for. Defaults to None, in which case poses are set - for all prims in the view. - - Raises: - ValueError: If translations shape is not (M, 3) or orientations shape is not (M, 4). - ValueError: If the number of poses doesn't match the number of indices provided. - """ - if self._use_fabric: - self._set_local_poses_fabric(translations, orientations, indices) - else: - self._set_local_poses_usd(translations, orientations, indices) - - def set_scales(self, scales: torch.Tensor, indices: Sequence[int] | None = None): - """Set scales for prims in the view. - - This method sets the scale of each prim in the view. - - - When Fabric is enabled, the function updates scales in Fabric matrices using GPU-accelerated batch operations. - - When Fabric is disabled, the function writes to USD's ``xformOp:scale`` attributes. - - Args: - scales: Scales as a tensor of shape (M, 3) where M is the number of prims - to set (either all prims if indices is None, or the number of indices provided). - indices: Indices of prims to set scales for. Defaults to None, in which case scales are set - for all prims in the view. - - Raises: - ValueError: If scales shape is not (M, 3). - """ - if self._use_fabric: - self._set_scales_fabric(scales, indices) - else: - self._set_scales_usd(scales, indices) - - def set_visibility(self, visibility: torch.Tensor, indices: Sequence[int] | None = None): - """Set visibility for prims in the view. - - This method sets the visibility of each prim in the view. - - Args: - visibility: Visibility as a boolean tensor of shape (M,) where M is the - number of prims to set (either all prims if indices is None, or the number of indices provided). - indices: Indices of prims to set visibility for. Defaults to None, in which case visibility is set - for all prims in the view. - - Raises: - ValueError: If visibility shape is not (M,). - """ - # Resolve indices - if indices is None or indices == slice(None): - indices_list = self._ALL_INDICES - else: - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - - # Validate inputs - if visibility.shape != (len(indices_list),): - raise ValueError(f"Expected visibility shape ({len(indices_list)},), got {visibility.shape}.") - - # Set visibility for each prim - with Sdf.ChangeBlock(): - for idx, prim_idx in enumerate(indices_list): - # Convert prim to imageable - imageable = UsdGeom.Imageable(self._prims[prim_idx]) - # Set visibility - if visibility[idx]: - imageable.MakeVisible() - else: - imageable.MakeInvisible() - - """ - Operations - Getters. - """ - - def get_world_poses(self, indices: Sequence[int] | None = None) -> tuple[torch.Tensor, torch.Tensor]: - """Get world-space poses for prims in the view. - - This method retrieves the position and orientation of each prim in world space by computing - the full transform hierarchy from the prim to the world root. - - - When Fabric is enabled, the function uses Fabric batch operations with Warp kernels. - - When Fabric is disabled, the function uses USD XformCache. - - Note: - Scale and skew are ignored. The returned poses contain only translation and rotation. - - Args: - indices: Indices of prims to get poses for. Defaults to None, in which case poses are retrieved - for all prims in the view. - - Returns: - A tuple of (positions, orientations) where: - - - positions: Torch tensor of shape (M, 3) containing world-space positions (x, y, z), - where M is the number of prims queried. - - orientations: Torch tensor of shape (M, 4) containing world-space quaternions (w, x, y, z) - """ - if self._use_fabric: - return self._get_world_poses_fabric(indices) - else: - return self._get_world_poses_usd(indices) - - def get_local_poses(self, indices: Sequence[int] | None = None) -> tuple[torch.Tensor, torch.Tensor]: - """Get local-space poses for prims in the view. - - This method retrieves the position and orientation of each prim in local space (relative to - their parent prims). It reads directly from USD's ``xformOp:translate`` and ``xformOp:orient`` attributes. - - Note: - Even in Fabric mode, local pose operations use USD. This behavior is based on Isaac Sim's design - where Fabric is only used for world pose operations. - - Rationale: - - Local pose reads need correct parent-child hierarchy relationships - - USD maintains these relationships correctly and efficiently - - Fabric is optimized for world pose operations, not local hierarchies - - Note: - Scale is ignored. The returned poses contain only translation and rotation. - - Args: - indices: Indices of prims to get poses for. Defaults to None, in which case poses are retrieved - for all prims in the view. - - Returns: - A tuple of (translations, orientations) where: - - - translations: Torch tensor of shape (M, 3) containing local-space translations (x, y, z), - where M is the number of prims queried. - - orientations: Torch tensor of shape (M, 4) containing local-space quaternions (w, x, y, z) - """ - if self._use_fabric: - return self._get_local_poses_fabric(indices) - else: - return self._get_local_poses_usd(indices) - - def get_scales(self, indices: Sequence[int] | None = None) -> torch.Tensor: - """Get scales for prims in the view. - - This method retrieves the scale of each prim in the view. - - - When Fabric is enabled, the function extracts scales from Fabric matrices using batch operations with - Warp kernels. - - When Fabric is disabled, the function reads from USD's ``xformOp:scale`` attributes. - - Args: - indices: Indices of prims to get scales for. Defaults to None, in which case scales are retrieved - for all prims in the view. - - Returns: - A tensor of shape (M, 3) containing the scales of each prim, where M is the number of prims queried. - """ - if self._use_fabric: - return self._get_scales_fabric(indices) - else: - return self._get_scales_usd(indices) - - def get_visibility(self, indices: Sequence[int] | None = None) -> torch.Tensor: - """Get visibility for prims in the view. - - This method retrieves the visibility of each prim in the view. - - Args: - indices: Indices of prims to get visibility for. Defaults to None, in which case visibility is retrieved - for all prims in the view. - - Returns: - A tensor of shape (M,) containing the visibility of each prim, where M is the number of prims queried. - The tensor is of type bool. - """ - # Resolve indices - if indices is None or indices == slice(None): - indices_list = self._ALL_INDICES - else: - # Convert to list if it is a tensor array - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - - # Create buffers - visibility = torch.zeros(len(indices_list), dtype=torch.bool, device=self._device) - - for idx, prim_idx in enumerate(indices_list): - # Get prim - imageable = UsdGeom.Imageable(self._prims[prim_idx]) - # Get visibility - visibility[idx] = imageable.ComputeVisibility() != UsdGeom.Tokens.invisible - - return visibility - - """ - Internal Functions - USD. - """ - - def _set_world_poses_usd( - self, - positions: torch.Tensor | None = None, - orientations: torch.Tensor | None = None, - indices: Sequence[int] | None = None, - ): - """Set world poses to USD.""" - # Resolve indices - if indices is None or indices == slice(None): - indices_list = self._ALL_INDICES - else: - # Convert to list if it is a tensor array - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - - # Validate inputs - if positions is not None: - if positions.shape != (len(indices_list), 3): - raise ValueError( - f"Expected positions shape ({len(indices_list)}, 3), got {positions.shape}. " - "Number of positions must match the number of prims in the view." - ) - positions_array = Vt.Vec3dArray.FromNumpy(positions.cpu().numpy()) - else: - positions_array = None - if orientations is not None: - if orientations.shape != (len(indices_list), 4): - raise ValueError( - f"Expected orientations shape ({len(indices_list)}, 4), got {orientations.shape}. " - "Number of orientations must match the number of prims in the view." - ) - # Vt expects quaternions in xyzw order - orientations_array = Vt.QuatdArray.FromNumpy(orientations.cpu().numpy()) - else: - orientations_array = None - - # Create xform cache instance - xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) - - # Set poses for each prim - # We use Sdf.ChangeBlock to minimize notification overhead. - with Sdf.ChangeBlock(): - for idx, prim_idx in enumerate(indices_list): - # Get prim - prim = self._prims[prim_idx] - # Get parent prim for local space conversion - parent_prim = prim.GetParent() - - # Determine what to set - world_pos = positions_array[idx] if positions_array is not None else None - world_quat = orientations_array[idx] if orientations_array is not None else None - - # Convert world pose to local if we have a valid parent - # Note: We don't use :func:`isaaclab.sim.utils.transforms.convert_world_pose_to_local` - # here since it isn't optimized for batch operations. - if parent_prim.IsValid() and parent_prim.GetPath() != Sdf.Path.absoluteRootPath: - # Get current world pose if we're only setting one component - if positions_array is None or orientations_array is None: - # get prim xform - prim_tf = xform_cache.GetLocalToWorldTransform(prim) - # sanitize quaternion - # this is needed, otherwise the quaternion might be non-normalized - prim_tf.Orthonormalize() - # populate desired world transform - if world_pos is not None: - prim_tf.SetTranslateOnly(world_pos) - if world_quat is not None: - prim_tf.SetRotateOnly(world_quat) - else: - # Both position and orientation are provided, create new transform - prim_tf = Gf.Matrix4d() - prim_tf.SetTranslateOnly(world_pos) - prim_tf.SetRotateOnly(world_quat) - - # Convert to local space - parent_world_tf = xform_cache.GetLocalToWorldTransform(parent_prim) - local_tf = prim_tf * parent_world_tf.GetInverse() - local_pos = local_tf.ExtractTranslation() - local_quat = local_tf.ExtractRotationQuat() - else: - # No parent or parent is root, world == local - local_pos = world_pos - local_quat = world_quat - - # Get or create the standard transform operations - if local_pos is not None: - prim.GetAttribute("xformOp:translate").Set(local_pos) - if local_quat is not None: - prim.GetAttribute("xformOp:orient").Set(local_quat) - - def _set_local_poses_usd( - self, - translations: torch.Tensor | None = None, - orientations: torch.Tensor | None = None, - indices: Sequence[int] | None = None, - ): - """Set local poses to USD.""" - # Resolve indices - if indices is None or indices == slice(None): - indices_list = self._ALL_INDICES - else: - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - - # Validate inputs - if translations is not None: - if translations.shape != (len(indices_list), 3): - raise ValueError(f"Expected translations shape ({len(indices_list)}, 3), got {translations.shape}.") - translations_array = Vt.Vec3dArray.FromNumpy(translations.cpu().numpy()) - else: - translations_array = None - if orientations is not None: - if orientations.shape != (len(indices_list), 4): - raise ValueError(f"Expected orientations shape ({len(indices_list)}, 4), got {orientations.shape}.") - orientations_array = Vt.QuatdArray.FromNumpy(orientations.cpu().numpy()) - else: - orientations_array = None - - # Set local poses - with Sdf.ChangeBlock(): - for idx, prim_idx in enumerate(indices_list): - prim = self._prims[prim_idx] - if translations_array is not None: - prim.GetAttribute("xformOp:translate").Set(translations_array[idx]) - if orientations_array is not None: - prim.GetAttribute("xformOp:orient").Set(orientations_array[idx]) - - def _set_scales_usd(self, scales: torch.Tensor, indices: Sequence[int] | None = None): - """Set scales to USD.""" - # Resolve indices - if indices is None or indices == slice(None): - indices_list = self._ALL_INDICES - else: - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - - # Validate inputs - if scales.shape != (len(indices_list), 3): - raise ValueError(f"Expected scales shape ({len(indices_list)}, 3), got {scales.shape}.") - - scales_array = Vt.Vec3dArray.FromNumpy(scales.cpu().numpy()) - # Set scales for each prim - with Sdf.ChangeBlock(): - for idx, prim_idx in enumerate(indices_list): - prim = self._prims[prim_idx] - prim.GetAttribute("xformOp:scale").Set(scales_array[idx]) - - def _get_world_poses_usd(self, indices: Sequence[int] | None = None) -> tuple[torch.Tensor, torch.Tensor]: - """Get world poses from USD.""" - # Resolve indices - if indices is None or indices == slice(None): - indices_list = self._ALL_INDICES - else: - # Convert to list if it is a tensor array - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - - # Create buffers - positions = Vt.Vec3dArray(len(indices_list)) - orientations = Vt.QuatdArray(len(indices_list)) - # Create xform cache instance - xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) - - # Note: We don't use :func:`isaaclab.sim.utils.transforms.resolve_prim_pose` - # here since it isn't optimized for batch operations. - for idx, prim_idx in enumerate(indices_list): - # Get prim - prim = self._prims[prim_idx] - # get prim xform - prim_tf = xform_cache.GetLocalToWorldTransform(prim) - # sanitize quaternion - # this is needed, otherwise the quaternion might be non-normalized - prim_tf.Orthonormalize() - # extract position and orientation - positions[idx] = prim_tf.ExtractTranslation() - orientations[idx] = prim_tf.ExtractRotationQuat() - - # move to torch tensors - positions = torch.tensor(np.array(positions), dtype=torch.float32, device=self._device) - orientations = torch.tensor(np.array(orientations), dtype=torch.float32, device=self._device) - return positions, orientations # type: ignore - - def _get_local_poses_usd(self, indices: Sequence[int] | None = None) -> tuple[torch.Tensor, torch.Tensor]: - """Get local poses from USD.""" - # Resolve indices - if indices is None or indices == slice(None): - indices_list = self._ALL_INDICES - else: - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - - # Create buffers - translations = Vt.Vec3dArray(len(indices_list)) - orientations = Vt.QuatdArray(len(indices_list)) - - # Create a fresh XformCache to avoid stale cached values - xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) - - for idx, prim_idx in enumerate(indices_list): - prim = self._prims[prim_idx] - prim_tf = xform_cache.GetLocalTransformation(prim)[0] - prim_tf.Orthonormalize() - translations[idx] = prim_tf.ExtractTranslation() - orientations[idx] = prim_tf.ExtractRotationQuat() - - translations = torch.tensor(np.array(translations), dtype=torch.float32, device=self._device) - orientations = torch.tensor(np.array(orientations), dtype=torch.float32, device=self._device) - return translations, orientations # type: ignore - - def _get_scales_usd(self, indices: Sequence[int] | None = None) -> torch.Tensor: - """Get scales from USD.""" - # Resolve indices - if indices is None or indices == slice(None): - indices_list = self._ALL_INDICES - else: - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - - # Create buffers - scales = Vt.Vec3dArray(len(indices_list)) - - for idx, prim_idx in enumerate(indices_list): - prim = self._prims[prim_idx] - scales[idx] = prim.GetAttribute("xformOp:scale").Get() - - # Convert to tensor - return torch.tensor(np.array(scales), dtype=torch.float32, device=self._device) - - """ - Internal Functions - Fabric. - """ - - def _set_world_poses_fabric( - self, - positions: torch.Tensor | None = None, - orientations: torch.Tensor | None = None, - indices: Sequence[int] | None = None, - ): - """Set world poses using Fabric GPU batch operations. - - Writes directly to Fabric's ``omni:fabric:worldMatrix`` attribute using Warp kernels. - Changes are propagated through Fabric's hierarchy system but remain GPU-resident. - - For workflows mixing Fabric world pose writes with USD local pose queries, note - that local poses read from USD's xformOp:* attributes, which may not immediately - reflect Fabric changes. For best performance and consistency, use Fabric methods - exclusively (get_world_poses/set_world_poses with Fabric enabled). - """ - # Lazy initialization - if not self._fabric_initialized: - self._initialize_fabric() - - # Resolve indices (treat slice(None) as None for consistency with USD path) - indices_wp = self._resolve_indices_wp(indices) - - count = indices_wp.shape[0] - - # Convert torch to warp (if provided), use dummy arrays for None to avoid Warp kernel issues - if positions is not None: - positions_wp = wp.from_torch(positions) - else: - positions_wp = wp.zeros((0, 3), dtype=wp.float32).to(self._device) - - if orientations is not None: - orientations_wp = wp.from_torch(orientations) - else: - orientations_wp = wp.zeros((0, 4), dtype=wp.float32).to(self._device) - - # Dummy array for scales (not modifying) - scales_wp = wp.zeros((0, 3), dtype=wp.float32).to(self._device) - - # Use cached fabricarray for world matrices - world_matrices = self._fabric_world_matrices - - # Batch compose matrices with a single kernel launch - # Note: world_matrices is a fabricarray on fabric_device, so we must launch on fabric_device - wp.launch( - kernel=fabric_utils.compose_fabric_transformation_matrix_from_warp_arrays, - dim=count, - inputs=[ - world_matrices, - positions_wp, - orientations_wp, - scales_wp, # dummy array instead of None - False, # broadcast_positions - False, # broadcast_orientations - False, # broadcast_scales - indices_wp, - self._view_to_fabric, - ], - device=self._fabric_device, - ) - - # Synchronize to ensure kernel completes - wp.synchronize() - - # Update world transforms within Fabric hierarchy - self._fabric_hierarchy.update_world_xforms() - # Fabric now has authoritative data; skip future USD syncs - self._fabric_usd_sync_done = True - # Mirror to USD for renderer-facing prims when enabled. - if self._sync_usd_on_fabric_write: - self._set_world_poses_usd(positions, orientations, indices) - - # Fabric writes are GPU-resident; local pose operations still use USD. - - def _set_local_poses_fabric( - self, - translations: torch.Tensor | None = None, - orientations: torch.Tensor | None = None, - indices: Sequence[int] | None = None, - ): - """Set local poses using USD (matches Isaac Sim's design). - - Note: Even in Fabric mode, local pose operations use USD. - This is Isaac Sim's design: the ``usd=False`` parameter only affects world poses. - - Rationale: - - Local pose writes need correct parent-child hierarchy relationships - - USD maintains these relationships correctly and efficiently - - Fabric is optimized for world pose operations, not local hierarchies - """ - self._set_local_poses_usd(translations, orientations, indices) - - def _set_scales_fabric(self, scales: torch.Tensor, indices: Sequence[int] | None = None): - """Set scales using Fabric GPU batch operations.""" - # Lazy initialization - if not self._fabric_initialized: - self._initialize_fabric() - - # Resolve indices (treat slice(None) as None for consistency with USD path) - indices_wp = self._resolve_indices_wp(indices) - - count = indices_wp.shape[0] - - # Convert torch to warp - scales_wp = wp.from_torch(scales) - - # Dummy arrays for positions and orientations (not modifying) - positions_wp = wp.zeros((0, 3), dtype=wp.float32).to(self._device) - orientations_wp = wp.zeros((0, 4), dtype=wp.float32).to(self._device) - - # Use cached fabricarray for world matrices - world_matrices = self._fabric_world_matrices - - # Batch compose matrices on GPU with a single kernel launch - # Note: world_matrices is a fabricarray on fabric_device, so we must launch on fabric_device - wp.launch( - kernel=fabric_utils.compose_fabric_transformation_matrix_from_warp_arrays, - dim=count, - inputs=[ - world_matrices, - positions_wp, # dummy array instead of None - orientations_wp, # dummy array instead of None - scales_wp, - False, # broadcast_positions - False, # broadcast_orientations - False, # broadcast_scales - indices_wp, - self._view_to_fabric, - ], - device=self._fabric_device, - ) - - # Synchronize to ensure kernel completes before syncing - wp.synchronize() - - # Update world transforms to propagate changes - self._fabric_hierarchy.update_world_xforms() - # Fabric now has authoritative data; skip future USD syncs - self._fabric_usd_sync_done = True - # Mirror to USD for renderer-facing prims when enabled. - if self._sync_usd_on_fabric_write: - self._set_scales_usd(scales, indices) - - def _get_world_poses_fabric(self, indices: Sequence[int] | None = None) -> tuple[torch.Tensor, torch.Tensor]: - """Get world poses from Fabric using GPU batch operations.""" - # Lazy initialization of Fabric infrastructure - if not self._fabric_initialized: - self._initialize_fabric() - # Sync once from USD to ensure reads see the latest authored transforms - if not self._fabric_usd_sync_done: - self._sync_fabric_from_usd_once() - - # Resolve indices (treat slice(None) as None for consistency with USD path) - indices_wp = self._resolve_indices_wp(indices) - - count = indices_wp.shape[0] - - # Use pre-allocated buffers for full reads, allocate only for partial reads - use_cached_buffers = indices is None or indices == slice(None) - if use_cached_buffers: - # Full read: Use cached buffers (zero allocation overhead!) - positions_wp = self._fabric_positions_buffer - orientations_wp = self._fabric_orientations_buffer - scales_wp = self._fabric_dummy_buffer - else: - # Partial read: Need to allocate buffers of appropriate size - positions_wp = wp.zeros((count, 3), dtype=wp.float32).to(self._device) - orientations_wp = wp.zeros((count, 4), dtype=wp.float32).to(self._device) - scales_wp = self._fabric_dummy_buffer # Always use dummy for scales - - # Use cached fabricarray for world matrices - # This eliminates the 0.06-0.30ms variability from creating fabricarray each call - world_matrices = self._fabric_world_matrices - - # Launch GPU kernel to decompose matrices in parallel - # Note: world_matrices is a fabricarray on fabric_device, so we must launch on fabric_device - wp.launch( - kernel=fabric_utils.decompose_fabric_transformation_matrix_to_warp_arrays, - dim=count, - inputs=[ - world_matrices, - positions_wp, - orientations_wp, - scales_wp, # dummy array instead of None - indices_wp, - self._view_to_fabric, - ], - device=self._fabric_device, - ) - - # Return tensors: zero-copy for cached buffers, conversion for partial reads - if use_cached_buffers: - # Zero-copy! The Warp kernel wrote directly into the PyTorch tensors - # We just need to synchronize to ensure the kernel is done - wp.synchronize() - return self._fabric_positions_torch, self._fabric_orientations_torch - else: - # Partial read: Need to convert from Warp to torch - positions = wp.to_torch(positions_wp) - orientations = wp.to_torch(orientations_wp) - return positions, orientations - - def _get_local_poses_fabric(self, indices: Sequence[int] | None = None) -> tuple[torch.Tensor, torch.Tensor]: - """Get local poses using USD (matches Isaac Sim's design). - - Note: - Even in Fabric mode, local pose operations use USD's XformCache. - This is Isaac Sim's design: the ``usd=False`` parameter only affects world poses. - - Rationale: - - Local pose computation requires parent transforms which may not be in the view - - USD's XformCache provides efficient hierarchy-aware local transform queries - - Fabric is optimized for world pose operations, not local hierarchies - """ - return self._get_local_poses_usd(indices) - - def _get_scales_fabric(self, indices: Sequence[int] | None = None) -> torch.Tensor: - """Get scales from Fabric using GPU batch operations.""" - # Lazy initialization - if not self._fabric_initialized: - self._initialize_fabric() - # Sync once from USD to ensure reads see the latest authored transforms - if not self._fabric_usd_sync_done: - self._sync_fabric_from_usd_once() - - # Resolve indices (treat slice(None) as None for consistency with USD path) - indices_wp = self._resolve_indices_wp(indices) - - count = indices_wp.shape[0] - - # Use pre-allocated buffers for full reads, allocate only for partial reads - use_cached_buffers = indices is None or indices == slice(None) - if use_cached_buffers: - # Full read: Use cached buffers (zero allocation overhead!) - scales_wp = self._fabric_scales_buffer - else: - # Partial read: Need to allocate buffer of appropriate size - scales_wp = wp.zeros((count, 3), dtype=wp.float32).to(self._device) - - # Always use dummy buffers for positions and orientations (not needed for scales) - positions_wp = self._fabric_dummy_buffer - orientations_wp = self._fabric_dummy_buffer - - # Use cached fabricarray for world matrices - world_matrices = self._fabric_world_matrices - - # Launch GPU kernel to decompose matrices in parallel - # Note: world_matrices is a fabricarray on fabric_device, so we must launch on fabric_device - wp.launch( - kernel=fabric_utils.decompose_fabric_transformation_matrix_to_warp_arrays, - dim=count, - inputs=[ - world_matrices, - positions_wp, # dummy array instead of None - orientations_wp, # dummy array instead of None - scales_wp, - indices_wp, - self._view_to_fabric, - ], - device=self._fabric_device, - ) - - # Return tensor: zero-copy for cached buffers, conversion for partial reads - if use_cached_buffers: - # Zero-copy! The Warp kernel wrote directly into the PyTorch tensor - wp.synchronize() - return self._fabric_scales_torch - else: - # Partial read: Need to convert from Warp to torch - return wp.to_torch(scales_wp) - - """ - Internal Functions - Initialization. - """ - - def _initialize_fabric(self) -> None: - """Initialize Fabric batch infrastructure for GPU-accelerated pose queries. - - This method ensures all prims have the required Fabric hierarchy attributes - (``omni:fabric:localMatrix`` and ``omni:fabric:worldMatrix``) and creates the necessary - infrastructure for batch GPU operations using Warp. - - Based on the Fabric Hierarchy documentation, when Fabric Scene Delegate is enabled, - all boundable prims should have these attributes. This method ensures they exist - and are properly synchronized with USD. - """ - import usdrt - from usdrt import Rt - - # Get USDRT (Fabric) stage - stage_id = sim_utils.get_current_stage_id() - fabric_stage = usdrt.Usd.Stage.Attach(stage_id) - - # Step 1: Ensure all prims have Fabric hierarchy attributes - # According to the documentation, these attributes are created automatically - # when Fabric Scene Delegate is enabled, but we ensure they exist - for i in range(self.count): - rt_prim = fabric_stage.GetPrimAtPath(self.prim_paths[i]) - rt_xformable = Rt.Xformable(rt_prim) - - # Create Fabric hierarchy world matrix attribute if it doesn't exist - has_attr = ( - rt_xformable.HasFabricHierarchyWorldMatrixAttr() - if hasattr(rt_xformable, "HasFabricHierarchyWorldMatrixAttr") - else False - ) - if not has_attr: - rt_xformable.CreateFabricHierarchyWorldMatrixAttr() - - # Best-effort USD->Fabric sync; authoritative initialization happens on first read. - rt_xformable.SetWorldXformFromUsd() - - # Create view index attribute for batch operations - rt_prim.CreateAttribute(self._view_index_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True) - rt_prim.GetAttribute(self._view_index_attr).Set(i) - - # After syncing all prims, update the Fabric hierarchy to ensure world matrices are computed - self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId() - ) - self._fabric_hierarchy.update_world_xforms() - - # Step 2: Create index arrays for batch operations - self._default_view_indices = wp.zeros((self.count,), dtype=wp.uint32).to(self._device) - wp.launch( - kernel=fabric_utils.arange_k, - dim=self.count, - inputs=[self._default_view_indices], - device=self._device, - ) - wp.synchronize() # Ensure indices are ready - - # Step 3: Create Fabric selection with attribute filtering - # SelectPrims expects device format like "cuda:0" not "cuda" - # - # KNOWN ISSUE: SelectPrims may return prims in a different order than self._prims - # (which comes from USD's find_matching_prims). We create a bidirectional mapping - # (_view_to_fabric and _fabric_to_view) to handle this ordering difference. - # This works correctly for full-view operations but partial indexing still has issues. - # - # NOTE: SelectPrims only supports "cuda:0" regardless of which GPU the simulation - # is running on. In multi-GPU setups, we must use "cuda:0" for SelectPrims even if - # the simulation device is "cuda:1" or higher. - fabric_device = self._device - if self._device == "cuda": - logger.warning("Fabric device is not specified, defaulting to 'cuda:0'.") - fabric_device = "cuda:0" - elif self._device.startswith("cuda:"): - # SelectPrims only supports cuda:0, so we always use cuda:0 for SelectPrims - # even if the simulation is running on a different GPU - if self._device != "cuda:0": - logger.debug( - f"SelectPrims only supports cuda:0. Using cuda:0 for SelectPrims " - f"even though simulation device is {self._device}." - ) - fabric_device = "cuda:0" - - self._fabric_selection = fabric_stage.SelectPrims( - require_attrs=[ - (usdrt.Sdf.ValueTypeNames.UInt, self._view_index_attr, usdrt.Usd.Access.Read), - (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite), - ], - device=fabric_device, - ) - - # Step 4: Create bidirectional mapping between view and fabric indices - # Note: fabric_to_view is tied to fabric_device (cuda:0) because it's created from SelectPrims. - # view_to_fabric must also be on fabric_device since it's always used with fabricarrays in kernels. - self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32).to(fabric_device) - self._fabric_to_view = wp.fabricarray(self._fabric_selection, self._view_index_attr) - - wp.launch( - kernel=fabric_utils.set_view_to_fabric_array, - dim=self._fabric_to_view.shape[0], - inputs=[self._fabric_to_view, self._view_to_fabric], - device=fabric_device, - ) - # Synchronize to ensure mapping is ready before any operations - wp.synchronize() - - # Pre-allocate reusable output buffers for read operations - self._fabric_positions_torch = torch.zeros((self.count, 3), dtype=torch.float32, device=self._device) - self._fabric_orientations_torch = torch.zeros((self.count, 4), dtype=torch.float32, device=self._device) - self._fabric_scales_torch = torch.zeros((self.count, 3), dtype=torch.float32, device=self._device) - - # Create Warp views of the PyTorch tensors - self._fabric_positions_buffer = wp.from_torch(self._fabric_positions_torch, dtype=wp.float32) - self._fabric_orientations_buffer = wp.from_torch(self._fabric_orientations_torch, dtype=wp.float32) - self._fabric_scales_buffer = wp.from_torch(self._fabric_scales_torch, dtype=wp.float32) - - # Dummy array for unused outputs (always empty) - self._fabric_dummy_buffer = wp.zeros((0, 3), dtype=wp.float32).to(self._device) - - # Cache fabricarray for world matrices to avoid recreation overhead - # Refs: https://docs.omniverse.nvidia.com/kit/docs/usdrt/latest/docs/usdrt_prim_selection.html - # https://docs.omniverse.nvidia.com/kit/docs/usdrt/latest/docs/scenegraph_use.html - self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") - - # Cache Fabric stage to avoid expensive get_current_stage() calls - self._fabric_stage = fabric_stage - - # Store fabric_device for use in kernel launches that involve fabricarrays - self._fabric_device = fabric_device - - self._fabric_initialized = True - # Force a one-time USD->Fabric sync on first read to pick up any USD edits - # made after the view was constructed. - self._fabric_usd_sync_done = False - - def _sync_fabric_from_usd_once(self) -> None: - """Sync Fabric world matrices from USD once, on the first read.""" - # Ensure Fabric is initialized - if not self._fabric_initialized: - self._initialize_fabric() - - # Read authoritative transforms from USD and write once into Fabric. - positions_usd, orientations_usd = self._get_world_poses_usd() - scales_usd = self._get_scales_usd() - - prev_sync = self._sync_usd_on_fabric_write - self._sync_usd_on_fabric_write = False - self._set_world_poses_fabric(positions_usd, orientations_usd) - self._set_scales_fabric(scales_usd) - self._sync_usd_on_fabric_write = prev_sync - - self._fabric_usd_sync_done = True - - def _resolve_indices_wp(self, indices: Sequence[int] | None) -> wp.array: - """Resolve view indices as a Warp array.""" - if indices is None or indices == slice(None): - if self._default_view_indices is None: - raise RuntimeError("Fabric indices are not initialized.") - return self._default_view_indices - indices_list = indices.tolist() if isinstance(indices, torch.Tensor) else list(indices) - return wp.array(indices_list, dtype=wp.uint32).to(self._device) +XformPrimView = FrameView diff --git a/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py b/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py index 03221e1ce366..4824d968284f 100644 --- a/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py +++ b/source/isaaclab/test/sensors/check_multi_mesh_ray_caster.py @@ -142,7 +142,7 @@ def main(): prim_path="/World/envs/env_.*/ball", mesh_prim_paths=mesh_targets, pattern_cfg=patterns.GridPatternCfg(resolution=0.1, size=(1.6, 1.0)), - attach_yaw_only=True, + ray_alignment="yaw", debug_vis=not args_cli.headless, ) ray_caster = MultiMeshRayCaster(cfg=ray_caster_cfg) diff --git a/source/isaaclab/test/sensors/test_ray_caster.py b/source/isaaclab/test/sensors/test_ray_caster.py index 5dffb5ccd015..4e29b25ce351 100644 --- a/source/isaaclab/test/sensors/test_ray_caster.py +++ b/source/isaaclab/test/sensors/test_ray_caster.py @@ -243,6 +243,84 @@ def test_raycast_random_cube(raycast_setup): torch.testing.assert_close(ray_face_id, ray_face_id_m) +## +# RayCaster sensor-level tests +## + + +def test_raycaster_offset_does_not_affect_pos_w(): + """Verify that cfg.offset.pos shifts ray starts but NOT data.pos_w. + + data.pos_w must reflect the parent body position so that downstream + observations like height_scan (pos_w_z - hit_z - 0.5) produce values + relative to the body, not relative to the offset sensor frame. + + Regression test: previously the offset was baked into the FrameView's + Xform local transform, causing data.pos_w to include the 20m offset + and breaking height-scan observations during training. + """ + import isaaclab.sim as sim_utils + from isaaclab.sensors.ray_caster import RayCaster, RayCasterCfg, patterns + from isaaclab.terrains.trimesh.utils import make_plane + from isaaclab.terrains.utils import create_prim_from_mesh + + sim_utils.create_new_stage() + + # ground plane at z=0 + mesh = make_plane(size=(100, 100), height=0.0, center_zero=True) + create_prim_from_mesh("/World/ground", mesh) + + # parent body at known position + body_pos = (0.0, 0.0, 0.6) + sim_utils.create_prim("/World/Robot", "Xform", translation=body_pos) + + # large z-offset to make the regression obvious + offset_z = 20.0 + cfg = RayCasterCfg( + prim_path="/World/Robot", + offset=RayCasterCfg.OffsetCfg(pos=(0.0, 0.0, offset_z)), + mesh_prim_paths=["/World/ground"], + pattern_cfg=patterns.GridPatternCfg(resolution=0.5, size=[1.0, 1.0]), + ray_alignment="yaw", + ) + + dt = 0.01 + sim = sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=dt)) + + sensor = RayCaster(cfg) + sim.reset() + sensor.update(dt) + + # data.pos_w / data.ray_hits_w are wp.array after the ray caster warp-backend + # migration (PR #4967); convert to torch views for indexing. + pos_w = wp.to_torch(sensor.data.pos_w)[0].cpu() + + # pos_w.z should be near the body height, NOT body_height + offset + assert abs(pos_w[2].item() - body_pos[2]) < 1.0, ( + f"data.pos_w.z = {pos_w[2].item():.2f}, expected near body height {body_pos[2]}." + f" If pos_w.z ≈ {body_pos[2] + offset_z}, the offset was incorrectly baked into the FrameView." + ) + + # ray_hits should be near z=0 (ground plane) + hits_z = wp.to_torch(sensor.data.ray_hits_w)[0, :, 2].cpu() + valid = hits_z[~torch.isinf(hits_z)] + if len(valid) > 0: + assert valid.abs().max().item() < 2.0, ( + f"Ray hits z range [{valid.min().item():.2f}, {valid.max().item():.2f}] — expected near ground (z≈0)." + ) + + # height_scan observation: pos_w_z - hit_z - 0.5 should be small, not ~20 + if len(valid) > 0: + height_obs = pos_w[2].item() - valid.mean().item() - 0.5 + assert abs(height_obs) < 5.0, ( + f"height_scan observation = {height_obs:.2f}, expected near 0." + f" If ≈{offset_z}, the offset leaked into data.pos_w." + ) + + sim.stop() + sim.clear_instance() + + # --------------------------------------------------------------------------- # Tests for raycast_mesh_masked_kernel (new kernel in utils/warp/kernels.py) # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/sim/frame_view_contract_utils.py b/source/isaaclab/test/sim/frame_view_contract_utils.py new file mode 100644 index 000000000000..37734fee4322 --- /dev/null +++ b/source/isaaclab/test/sim/frame_view_contract_utils.py @@ -0,0 +1,359 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Shared FrameView contract tests. + +This module defines the invariants that **every** FrameView backend +(USD, Fabric, Newton) must satisfy. Backend test files import these tests +via ``from frame_view_contract_utils import *`` and provide a +``view_factory`` pytest fixture that builds the backend-specific scene. + +The factory signature is:: + + def view_factory() -> Callable[[int, str], ViewBundle]: ... + +Where ``ViewBundle`` is a :class:`NamedTuple`:: + + class ViewBundle(NamedTuple): + view: BaseFrameView + get_parent_pos: Callable[[int, str], torch.Tensor] + set_parent_pos: Callable[[torch.Tensor, int], None] + teardown: Callable[[], None] + +- ``view``: The FrameView under test. Must track child prims at + :data:`CHILD_OFFSET` under parent prims/bodies. +- ``get_parent_pos(n, device)``: Read the parent prim/body positions. +- ``set_parent_pos(positions, n)``: Write the parent prim/body positions. +- ``teardown()``: Cleanup (close context, clear stage, etc.). + +Tolerance policy: + - Indexed reads (exact copy): ``atol=0`` + - Composition / decomposition through float32 transforms: ``atol=ATOL`` + - Parent position identity checks (should be untouched): ``atol=0`` +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import NamedTuple + +import pytest +import torch +import warp as wp + +CHILD_OFFSET = (0.1, 0.0, 0.05) +"""Local offset of the child prim from its parent, shared by all backend fixtures.""" + +ATOL = 1e-5 +"""Default absolute tolerance for float32 transform composition.""" + + +class ViewBundle(NamedTuple): + """Return type of the ``view_factory`` fixture.""" + + view: object + get_parent_pos: Callable + set_parent_pos: Callable + teardown: Callable + + +def _t(a): + """Convert wp.array to torch.Tensor (pass-through for Tensor).""" + return wp.to_torch(a) if isinstance(a, wp.array) else a + + +def _wp_vec3f(data, device="cpu"): + return wp.array([wp.vec3f(*row) for row in data], dtype=wp.vec3f, device=device) + + +def _wp_vec4f(data, device="cpu"): + return wp.array([wp.vec4f(*row) for row in data], dtype=wp.vec4f, device=device) + + +# ================================================================== +# Contract: Getters +# ================================================================== + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_pose_equals_parent_plus_offset(device, view_factory): + """world_pose == parent_pos + local offset (identity parent orientation).""" + bundle = view_factory(num_envs=4, device=device) + try: + child_pos = _t(bundle.view.get_world_poses()[0]) + parent_pos = bundle.get_parent_pos(4, device) + offset = torch.tensor(CHILD_OFFSET, device=device) + + torch.testing.assert_close(child_pos, parent_pos + offset.unsqueeze(0), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_pose_equals_structural_offset(device, view_factory): + """local_pose == the authored offset (0.1, 0, 0.05) for every prim.""" + bundle = view_factory(num_envs=4, device=device) + try: + local_pos, local_quat = bundle.view.get_local_poses() + expected_pos = torch.tensor(CHILD_OFFSET, device=device).expand(4, -1) + expected_quat = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device).expand(4, -1) + + torch.testing.assert_close(_t(local_pos), expected_pos, atol=ATOL, rtol=0) + torch.testing.assert_close(_t(local_quat), expected_quat, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_differs_from_world(device, view_factory): + """local != world when parent is not at the origin. + + Asserts |world - local| > 0.5 to catch any implementation that returns + world as local. The parent is offset from the origin so the z-component + alone provides > 0.5 difference. + """ + bundle = view_factory(num_envs=2, device=device) + try: + world_pos = _t(bundle.view.get_world_poses()[0]) + local_pos = _t(bundle.view.get_local_poses()[0]) + + diff = (world_pos - local_pos).abs().max().item() + assert diff > 0.5, ( + f"Expected |world - local| > 0.5, got {diff:.4f}. world={world_pos.tolist()}, local={local_pos.tolist()}" + ) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_local_stable_after_parent_move(device, view_factory): + """Moving the parent changes world but NOT local.""" + bundle = view_factory(num_envs=2, device=device) + try: + local_before = _t(bundle.view.get_local_poses()[0]).clone() + bundle.set_parent_pos(torch.tensor([[99.0, 0.0, 0.0], [0.0, 99.0, 0.0]], device=device), 2) + local_after = _t(bundle.view.get_local_poses()[0]) + + torch.testing.assert_close(local_after, local_before, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_tracks_parent_move(device, view_factory): + """Moving the parent shifts world poses by the same amount.""" + bundle = view_factory(num_envs=2, device=device) + try: + new_parent_pos = torch.tensor([[5.0, 0.0, 0.0], [0.0, 5.0, 0.0]], device=device) + bundle.set_parent_pos(new_parent_pos, 2) + + child_pos = _t(bundle.view.get_world_poses()[0]) + offset = torch.tensor(CHILD_OFFSET, device=device) + + torch.testing.assert_close(child_pos, new_parent_pos + offset.unsqueeze(0), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_indexed_get_returns_correct_subset(device, view_factory): + """Indexed get (out-of-order) returns exact copies for both world and local.""" + bundle = view_factory(num_envs=5, device=device) + try: + all_world = _t(bundle.view.get_world_poses()[0]) + all_local = _t(bundle.view.get_local_poses()[0]) + + indices_list = [4, 1, 3] + indices = wp.array(indices_list, dtype=wp.int32, device=device) + sub_world = _t(bundle.view.get_world_poses(indices)[0]) + sub_local = _t(bundle.view.get_local_poses(indices)[0]) + + for out_i, view_i in enumerate(indices_list): + torch.testing.assert_close(sub_world[out_i], all_world[view_i], atol=0, rtol=0) + torch.testing.assert_close(sub_local[out_i], all_local[view_i], atol=0, rtol=0) + finally: + bundle.teardown() + + +# ================================================================== +# Contract: Setters +# ================================================================== + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_world_roundtrip(device, view_factory): + """set_world_poses -> get_world_poses returns the same values.""" + bundle = view_factory(num_envs=2, device=device) + try: + new_pos = _wp_vec3f([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], device=device) + new_quat = _wp_vec4f([[0.0, 0.0, 0.7071068, 0.7071068], [0.0, 0.0, 0.0, 1.0]], device=device) + bundle.view.set_world_poses(new_pos, new_quat) + + ret_pos, ret_quat = bundle.view.get_world_poses() + torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) + torch.testing.assert_close(_t(ret_quat), _t(new_quat), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_local_roundtrip(device, view_factory): + """set_local_poses -> get_local_poses returns the same values.""" + bundle = view_factory(num_envs=2, device=device) + try: + new_pos = _wp_vec3f([[0.5, 0.3, 0.1], [0.2, 0.7, 0.4]], device=device) + new_quat = _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device) + bundle.view.set_local_poses(new_pos, new_quat) + + ret_pos, ret_quat = bundle.view.get_local_poses() + torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) + torch.testing.assert_close(_t(ret_quat), _t(new_quat), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_world_does_not_move_parent(device, view_factory): + """set_world_poses must not modify the parent prim/body position.""" + bundle = view_factory(num_envs=2, device=device) + try: + parent_before = bundle.get_parent_pos(2, device).clone() + bundle.view.set_world_poses( + _wp_vec3f([[99.0, 99.0, 99.0], [88.0, 88.0, 88.0]], device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) + parent_after = bundle.get_parent_pos(2, device) + + torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_local_does_not_move_parent(device, view_factory): + """set_local_poses must not modify the parent prim/body position.""" + bundle = view_factory(num_envs=2, device=device) + try: + parent_before = bundle.get_parent_pos(2, device).clone() + bundle.view.set_local_poses( + _wp_vec3f([[0.5, 0.5, 0.5], [1.0, 1.0, 1.0]], device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) + parent_after = bundle.get_parent_pos(2, device) + + torch.testing.assert_close(parent_after, parent_before, atol=0, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_world_updates_local(device, view_factory): + """After set_world_poses, get_local_poses reflects the new offset. + + Uses non-axis-aligned offsets to catch coordinate swap bugs. + """ + bundle = view_factory(num_envs=2, device=device) + try: + parent_pos = bundle.get_parent_pos(2, device) + desired_offset = torch.tensor([[0.3, 0.7, 0.2], [0.8, 0.1, 0.6]], device=device) + new_world = parent_pos + desired_offset + + bundle.view.set_world_poses( + _wp_vec3f(new_world.tolist(), device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) + + local_pos = _t(bundle.view.get_local_poses()[0]) + torch.testing.assert_close(local_pos, desired_offset, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_local_updates_world(device, view_factory): + """After set_local_poses, get_world_poses == parent + new_local. + + Uses non-axis-aligned offsets to catch coordinate swap bugs. + """ + bundle = view_factory(num_envs=2, device=device) + try: + parent_pos = bundle.get_parent_pos(2, device) + new_offset = torch.tensor([[0.4, 0.9, 0.15], [0.6, 0.2, 0.85]], device=device) + bundle.view.set_local_poses( + _wp_vec3f(new_offset.tolist(), device=device), + _wp_vec4f([[0.0, 0.0, 0.0, 1.0]] * 2, device=device), + ) + + world_pos = _t(bundle.view.get_world_poses()[0]) + torch.testing.assert_close(world_pos, parent_pos + new_offset, atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_world_partial_position_only(device, view_factory): + """Setting only positions: new positions written, orientations preserved.""" + bundle = view_factory(num_envs=2, device=device) + try: + _, orig_quat = bundle.view.get_world_poses() + new_pos = _wp_vec3f([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], device=device) + bundle.view.set_world_poses(positions=new_pos) + + ret_pos, ret_quat = bundle.view.get_world_poses() + torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) + torch.testing.assert_close(_t(ret_quat), _t(orig_quat), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_world_partial_orientation_only(device, view_factory): + """Setting only orientations: new orientations written, positions preserved.""" + bundle = view_factory(num_envs=2, device=device) + try: + orig_pos, _ = bundle.view.get_world_poses() + new_quat = _wp_vec4f([[0.0, 0.0, 0.7071068, 0.7071068], [0.7071068, 0.0, 0.0, 0.7071068]], device=device) + bundle.view.set_world_poses(orientations=new_quat) + + ret_pos, ret_quat = bundle.view.get_world_poses() + torch.testing.assert_close(_t(ret_pos), _t(orig_pos), atol=ATOL, rtol=0) + torch.testing.assert_close(_t(ret_quat), _t(new_quat), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_local_partial_position_only(device, view_factory): + """Setting only local translations: new translations written, orientations preserved.""" + bundle = view_factory(num_envs=2, device=device) + try: + _, orig_quat = bundle.view.get_local_poses() + new_pos = _wp_vec3f([[0.2, 0.3, 0.4], [0.5, 0.6, 0.7]], device=device) + bundle.view.set_local_poses(translations=new_pos) + + ret_pos, ret_quat = bundle.view.get_local_poses() + torch.testing.assert_close(_t(ret_pos), _t(new_pos), atol=ATOL, rtol=0) + torch.testing.assert_close(_t(ret_quat), _t(orig_quat), atol=ATOL, rtol=0) + finally: + bundle.teardown() + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_set_world_indexed_only_affects_subset(device, view_factory): + """Indexed set_world_poses writes requested indices, leaves others untouched.""" + bundle = view_factory(num_envs=4, device=device) + try: + orig_pos = _t(bundle.view.get_world_poses()[0]).clone() + indices = wp.array([1, 3], dtype=wp.int32, device=device) + new_pos = _wp_vec3f([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], device=device) + bundle.view.set_world_poses(positions=new_pos, indices=indices) + + updated = _t(bundle.view.get_world_poses()[0]) + torch.testing.assert_close(updated[0], orig_pos[0], atol=0, rtol=0) + torch.testing.assert_close(updated[2], orig_pos[2], atol=0, rtol=0) + torch.testing.assert_close(updated[1], _t(new_pos)[0], atol=ATOL, rtol=0) + torch.testing.assert_close(updated[3], _t(new_pos)[1], atol=ATOL, rtol=0) + finally: + bundle.teardown() diff --git a/source/isaaclab/test/sim/test_views_xform_prim.py b/source/isaaclab/test/sim/test_views_xform_prim.py index 3de7a0b357a2..2b40705f732a 100644 --- a/source/isaaclab/test/sim/test_views_xform_prim.py +++ b/source/isaaclab/test/sim/test_views_xform_prim.py @@ -3,1513 +3,294 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Launch Isaac Sim Simulator first.""" +"""USD backend tests for FrameView. + +Imports the shared contract tests and provides the USD-specific +``view_factory`` fixture. Also includes USD-only tests for visibility, +prim ordering, xformOp standardization, and Isaac Sim comparison. +""" from isaaclab.app import AppLauncher -# launch omniverse app simulation_app = AppLauncher(headless=True).app -"""Rest everything follows.""" - import pytest # noqa: E402 import torch # noqa: E402 +import warp as wp # noqa: E402 + +from pxr import Gf, UsdGeom # noqa: E402 try: from isaacsim.core.prims import XFormPrim as _IsaacSimXformPrimView except (ModuleNotFoundError, ImportError): _IsaacSimXformPrimView = None +from frame_view_contract_utils import * # noqa: F401, F403, E402 +from frame_view_contract_utils import CHILD_OFFSET, ViewBundle # noqa: E402 import isaaclab.sim as sim_utils # noqa: E402 -from isaaclab.sim.views import XformPrimView as XformPrimView # noqa: E402 +from isaaclab.sim.views import UsdFrameView as FrameView # noqa: E402 from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR # noqa: E402 +PARENT_POS = (0.0, 0.0, 1.0) + @pytest.fixture(autouse=True) def test_setup_teardown(): - """Create a blank new stage for each test.""" - # Setup: Create a new stage sim_utils.create_new_stage() sim_utils.update_stage() - - # Yield for the test yield - - # Teardown: Clear stage after each test sim_utils.clear_stage() sim_utils.SimulationContext.clear_instance() -""" -Helper functions. -""" - - -def _prepare_indices(index_type, target_indices, num_prims, device): - """Helper function to prepare indices based on type.""" - if index_type == "list": - return target_indices, target_indices - elif index_type == "torch_tensor": - return torch.tensor(target_indices, dtype=torch.int64, device=device), target_indices - elif index_type == "slice_none": - return slice(None), list(range(num_prims)) - else: - raise ValueError(f"Unknown index type: {index_type}") - - -def _skip_if_backend_unavailable(backend: str, device: str): - """Skip tests when the requested backend is unavailable.""" - if device.startswith("cuda") and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - if backend == "fabric" and device == "cpu": - pytest.skip("Warp fabricarray operations on CPU have known issues") - - -def _prim_type_for_backend(backend: str) -> str: - """Return a prim type that is compatible with the backend.""" - return "Camera" if backend == "fabric" else "Xform" - - -def _create_view(pattern: str, device: str, backend: str) -> XformPrimView: - """Create an XformPrimView for the requested backend.""" - if backend == "fabric": - sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) - return XformPrimView(pattern, device=device) - - -""" -Tests - Initialization. -""" - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_xform_prim_view_initialization_single_prim(device): - """Test XformPrimView initialization with a single prim.""" - # check if CUDA is available - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - # Create a single xform prim - stage = sim_utils.get_current_stage() - sim_utils.create_prim("/World/Object", "Xform", translation=(1.0, 2.0, 3.0), stage=stage) - - # Create view - view = XformPrimView("/World/Object", device=device) - - # Verify properties - assert view.count == 1 - assert view.prim_paths == ["/World/Object"] - assert view.device == device - assert len(view.prims) == 1 - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_xform_prim_view_initialization_multiple_prims(device): - """Test XformPrimView initialization with multiple prims using pattern matching.""" - # check if CUDA is available - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - # Create multiple prims - num_prims = 10 - stage = sim_utils.get_current_stage() - for i in range(num_prims): - sim_utils.create_prim(f"/World/Env_{i}/Object", "Xform", translation=(i * 2.0, 0.0, 1.0), stage=stage) - - # Create view with pattern - view = XformPrimView("/World/Env_.*/Object", device=device) - - # Verify properties - assert view.count == num_prims - assert view.device == device - assert len(view.prims) == num_prims - assert view.prim_paths == [f"/World/Env_{i}/Object" for i in range(num_prims)] - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_xform_prim_view_initialization_multiple_prims_order(device): - """Test XformPrimView initialization with multiple prims using pattern matching with multiple objects per prim. - - This test validates that XformPrimView respects USD stage traversal order, which is based on - creation order (depth-first search), NOT alphabetical/lexical sorting. This is an important - edge case that ensures deterministic prim ordering that matches USD's internal representation. - - The test creates prims in a deliberately non-alphabetical order (1, 0, A, a, 2) and verifies - that they are retrieved in creation order, not sorted order (0, 1, 2, A, a). - """ - # check if CUDA is available - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - # Create multiple prims - num_prims = 10 - stage = sim_utils.get_current_stage() - - # NOTE: Prims are created in a specific order to test that XformPrimView respects - # USD stage traversal order (DFS based on creation order), NOT alphabetical/lexical order. - # This is an important edge case: children under the same parent are returned in the - # order they were created, not sorted by name. - - # First batch: Create Object_1, Object_0, Object_A for each environment - # (intentionally non-alphabetical: 1, 0, A instead of 0, 1, A) - for i in range(num_prims): - sim_utils.create_prim(f"/World/Env_{i}/Object_1", "Xform", translation=(i * 2.0, -2.0, 1.0), stage=stage) - sim_utils.create_prim(f"/World/Env_{i}/Object_0", "Xform", translation=(i * 2.0, 2.0, 1.0), stage=stage) - sim_utils.create_prim(f"/World/Env_{i}/Object_A", "Xform", translation=(i * 2.0, 0.0, -1.0), stage=stage) - - # Second batch: Create Object_a, Object_2 for each environment - # (created after the first batch to verify traversal is depth-first per environment) - for i in range(num_prims): - sim_utils.create_prim(f"/World/Env_{i}/Object_a", "Xform", translation=(i * 2.0, 2.0, -1.0), stage=stage) - sim_utils.create_prim(f"/World/Env_{i}/Object_2", "Xform", translation=(i * 2.0, 2.0, 1.0), stage=stage) - - # Create view with pattern - view = XformPrimView("/World/Env_.*/Object_.*", device=device) - - # Expected ordering: DFS traversal by environment, with children in creation order - # For each Env_i, we expect: Object_1, Object_0, Object_A, Object_a, Object_2 - # (matches creation order, NOT alphabetical: would be 0, 1, 2, A, a if sorted) - expected_prim_paths_ordering = [] - for i in range(num_prims): - expected_prim_paths_ordering.append(f"/World/Env_{i}/Object_1") - expected_prim_paths_ordering.append(f"/World/Env_{i}/Object_0") - expected_prim_paths_ordering.append(f"/World/Env_{i}/Object_A") - expected_prim_paths_ordering.append(f"/World/Env_{i}/Object_a") - expected_prim_paths_ordering.append(f"/World/Env_{i}/Object_2") - - # Verify properties - assert view.count == num_prims * 5 - assert view.device == device - assert len(view.prims) == num_prims * 5 - assert view.prim_paths == expected_prim_paths_ordering - - # Additional validation: Verify ordering is NOT alphabetical - # If it were alphabetical, Object_0 would come before Object_1 - alphabetical_order = [] - for i in range(num_prims): - alphabetical_order.append(f"/World/Env_{i}/Object_0") - alphabetical_order.append(f"/World/Env_{i}/Object_1") - alphabetical_order.append(f"/World/Env_{i}/Object_2") - alphabetical_order.append(f"/World/Env_{i}/Object_A") - alphabetical_order.append(f"/World/Env_{i}/Object_a") - - assert view.prim_paths != alphabetical_order, ( - "Prim paths should follow creation order, not alphabetical order. " - "This test validates that USD stage traversal respects creation order." - ) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_xform_prim_view_standardizes_transform_op(device): - """Test that XformPrimView standardizes a prim with xformOp:transform to translate/orient/scale.""" - from pxr import Gf, UsdGeom - - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - expected_pos = (3.0, -1.0, 0.5) - matrix = Gf.Matrix4d(1.0) - matrix.SetTranslateOnly(Gf.Vec3d(*expected_pos)) - - stage = sim_utils.get_current_stage() - prim = stage.DefinePrim("/World/TransformPrim", "Xform") - UsdGeom.Xformable(prim).AddTransformOp().Set(matrix) - - view = XformPrimView("/World/TransformPrim", device=device) - - assert view.count == 1 - assert sim_utils.validate_standard_xform_ops(view.prims[0]) - - xformable = UsdGeom.Xformable(view.prims[0]) - ordered_ops = xformable.GetOrderedXformOps() - op_names = [op.GetOpName() for op in ordered_ops] - assert op_names == ["xformOp:translate", "xformOp:orient", "xformOp:scale"] - - assert ordered_ops[0].Get() == Gf.Vec3d(*expected_pos) - assert ordered_ops[1].Get() == Gf.Quatd(1.0, 0.0, 0.0, 0.0) - assert ordered_ops[2].Get() == Gf.Vec3d(1.0, 1.0, 1.0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_xform_prim_view_initialization_empty_pattern(device): - """Test XformPrimView initialization with pattern that matches no prims.""" - # check if CUDA is available - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - sim_utils.create_new_stage() - - # Create view with pattern that matches nothing - view = XformPrimView("/World/NonExistent_.*", device=device) - - # Should have zero count - assert view.count == 0 - assert len(view.prims) == 0 - - -""" -Tests - Getters. -""" - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_get_world_poses(device, backend): - """Test getting world poses from XformPrimView.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create prims with known world poses - expected_positions = [(1.0, 2.0, 3.0), (4.0, 5.0, 6.0), (7.0, 8.0, 9.0)] - expected_orientations = [(0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 0.7071068, 0.7071068), (0.7071068, 0.0, 0.0, 0.7071068)] - - for i, (pos, quat) in enumerate(zip(expected_positions, expected_orientations)): - sim_utils.create_prim(f"/World/Object_{i}", prim_type, translation=pos, orientation=quat, stage=stage) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - # Convert expected values to tensors - expected_positions_tensor = torch.tensor(expected_positions, dtype=torch.float32, device=device) - expected_orientations_tensor = torch.tensor(expected_orientations, dtype=torch.float32, device=device) - - # Get world poses - positions, orientations = view.get_world_poses() - - # Verify shapes - assert positions.shape == (3, 3) - assert orientations.shape == (3, 4) - - # Verify positions - torch.testing.assert_close(positions, expected_positions_tensor, atol=1e-5, rtol=0) - - # Verify orientations (allow for quaternion sign ambiguity) - try: - torch.testing.assert_close(orientations, expected_orientations_tensor, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(orientations, -expected_orientations_tensor, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_get_local_poses(device, backend): - """Test getting local poses from XformPrimView.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create parent and child prims - sim_utils.create_prim("/World/Parent", "Xform", translation=(10.0, 0.0, 0.0), stage=stage) - - # Children with different local poses - expected_local_positions = [(1.0, 0.0, 0.0), (0.0, 2.0, 0.0), (0.0, 0.0, 3.0)] - expected_local_orientations = [ - (0.0, 0.0, 0.0, 1.0), - (0.0, 0.0, 0.7071068, 0.7071068), - (0.7071068, 0.0, 0.0, 0.7071068), - ] - - for i, (pos, quat) in enumerate(zip(expected_local_positions, expected_local_orientations)): - sim_utils.create_prim(f"/World/Parent/Child_{i}", prim_type, translation=pos, orientation=quat, stage=stage) - - # Create view - view = _create_view("/World/Parent/Child_.*", device=device, backend=backend) - - # Get local poses - translations, orientations = view.get_local_poses() - - # Verify shapes - assert translations.shape == (3, 3) - assert orientations.shape == (3, 4) - - # Convert expected values to tensors - expected_translations_tensor = torch.tensor(expected_local_positions, dtype=torch.float32, device=device) - expected_orientations_tensor = torch.tensor(expected_local_orientations, dtype=torch.float32, device=device) - - # Verify translations - torch.testing.assert_close(translations, expected_translations_tensor, atol=1e-5, rtol=0) - - # Verify orientations (allow for quaternion sign ambiguity) - try: - torch.testing.assert_close(orientations, expected_orientations_tensor, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(orientations, -expected_orientations_tensor, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_get_scales(device, backend): - """Test getting scales from XformPrimView.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create prims with different scales - expected_scales = [(1.0, 1.0, 1.0), (2.0, 2.0, 2.0), (1.0, 2.0, 3.0)] - - for i, scale in enumerate(expected_scales): - sim_utils.create_prim(f"/World/Object_{i}", prim_type, scale=scale, stage=stage) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - expected_scales_tensor = torch.tensor(expected_scales, dtype=torch.float32, device=device) - - # Get scales - scales = view.get_scales() - - # Verify shape and values - assert scales.shape == (3, 3) - torch.testing.assert_close(scales, expected_scales_tensor, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_get_visibility(device): - """Test getting visibility when all prims are visible.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - stage = sim_utils.get_current_stage() - - # Create prims (default is visible) - num_prims = 5 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Object_{i}", "Xform", translation=(float(i), 0.0, 0.0), stage=stage) - - # Create view - view = XformPrimView("/World/Object_.*", device=device) - - # Get visibility - visibility = view.get_visibility() - - # Verify shape and values - assert visibility.shape == (num_prims,) - assert visibility.dtype == torch.bool - assert torch.all(visibility), "All prims should be visible by default" - - -""" -Tests - Setters. -""" - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_set_world_poses(device, backend): - """Test setting world poses in XformPrimView.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create prims - num_prims = 5 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Object_{i}", prim_type, translation=(0.0, 0.0, 0.0), stage=stage) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - # Set new world poses - new_positions = torch.tensor( - [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0], [10.0, 11.0, 12.0], [13.0, 14.0, 15.0]], device=device - ) - new_orientations = torch.tensor( - [ - [0.0, 0.0, 0.0, 1.0], - [0.0, 0.0, 0.7071068, 0.7071068], - [0.7071068, 0.0, 0.0, 0.7071068], - [0.3826834, 0.0, 0.0, 0.9238795], - [0.0, 0.7071068, 0.0, 0.7071068], - ], - device=device, - ) - - view.set_world_poses(new_positions, new_orientations) - - # Get the poses back - retrieved_positions, retrieved_orientations = view.get_world_poses() - - # Verify they match - torch.testing.assert_close(retrieved_positions, new_positions, atol=1e-5, rtol=0) - # Check quaternions (allow sign flip) - try: - torch.testing.assert_close(retrieved_orientations, new_orientations, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(retrieved_orientations, -new_orientations, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_set_world_poses_only_positions(device, backend): - """Test setting only positions, leaving orientations unchanged.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create prims with specific orientations - initial_quat = (0.0, 0.0, 0.7071068, 0.7071068) # 90 deg around Z - for i in range(3): - sim_utils.create_prim( - f"/World/Object_{i}", prim_type, translation=(0.0, 0.0, 0.0), orientation=initial_quat, stage=stage - ) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - # Get initial orientations - _, initial_orientations = view.get_world_poses() - - # Set only positions - new_positions = torch.tensor([[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]], device=device) - view.set_world_poses(positions=new_positions, orientations=None) - - # Get poses back - retrieved_positions, retrieved_orientations = view.get_world_poses() - - # Positions should be updated - torch.testing.assert_close(retrieved_positions, new_positions, atol=1e-5, rtol=0) - - # Orientations should be unchanged - try: - torch.testing.assert_close(retrieved_orientations, initial_orientations, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(retrieved_orientations, -initial_orientations, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_set_world_poses_only_orientations(device, backend): - """Test setting only orientations, leaving positions unchanged.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create prims with specific positions - for i in range(3): - sim_utils.create_prim(f"/World/Object_{i}", prim_type, translation=(float(i), 0.0, 0.0), stage=stage) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - # Get initial positions - initial_positions, _ = view.get_world_poses() - - # Set only orientations - new_orientations = torch.tensor( - [[0.0, 0.0, 0.7071068, 0.7071068], [0.7071068, 0.0, 0.0, 0.7071068], [0.3826834, 0.0, 0.0, 0.9238795]], - device=device, - ) - view.set_world_poses(positions=None, orientations=new_orientations) - - # Get poses back - retrieved_positions, retrieved_orientations = view.get_world_poses() - - # Positions should be unchanged - torch.testing.assert_close(retrieved_positions, initial_positions, atol=1e-5, rtol=0) - - # Orientations should be updated - try: - torch.testing.assert_close(retrieved_orientations, new_orientations, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(retrieved_orientations, -new_orientations, atol=1e-5, rtol=0) - +# ------------------------------------------------------------------ +# Contract fixture +# ------------------------------------------------------------------ -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_set_world_poses_with_hierarchy(device, backend): - """Test setting world poses correctly handles parent transformations.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - child_prim_type = _prim_type_for_backend(backend) - - # Create parent prims - for i in range(3): - parent_pos = (i * 10.0, 0.0, 0.0) - parent_quat = (0.0, 0.0, 0.7071068, 0.7071068) # 90 deg around Z - sim_utils.create_prim( - f"/World/Parent_{i}", "Xform", translation=parent_pos, orientation=parent_quat, stage=stage - ) - # Create child prims - sim_utils.create_prim(f"/World/Parent_{i}/Child", child_prim_type, translation=(0.0, 0.0, 0.0), stage=stage) - - # Create view for children - view = _create_view("/World/Parent_.*/Child", device=device, backend=backend) - - # Set world poses for children - desired_world_positions = torch.tensor([[5.0, 5.0, 0.0], [15.0, 5.0, 0.0], [25.0, 5.0, 0.0]], device=device) - desired_world_orientations = torch.tensor( - [[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0]], device=device - ) - - view.set_world_poses(desired_world_positions, desired_world_orientations) - - # Get world poses back - retrieved_positions, retrieved_orientations = view.get_world_poses() - - # Should match desired world poses - torch.testing.assert_close(retrieved_positions, desired_world_positions, atol=1e-4, rtol=0) - try: - torch.testing.assert_close(retrieved_orientations, desired_world_orientations, atol=1e-4, rtol=0) - except AssertionError: - torch.testing.assert_close(retrieved_orientations, -desired_world_orientations, atol=1e-4, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_set_local_poses(device, backend): - """Test setting local poses in XformPrimView.""" - _skip_if_backend_unavailable(backend, device) +def _get_parent_positions(num_envs, device="cpu"): + """Read parent Xform positions from USD.""" stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create parent - sim_utils.create_prim("/World/Parent", "Xform", translation=(5.0, 5.0, 5.0), stage=stage) - - # Create children - num_prims = 4 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Parent/Child_{i}", prim_type, translation=(0.0, 0.0, 0.0), stage=stage) - - # Create view - view = _create_view("/World/Parent/Child_.*", device=device, backend=backend) - - # Set new local poses - new_translations = torch.tensor([[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0], [4.0, 4.0, 4.0]], device=device) - new_orientations = torch.tensor( - [ - [0.0, 0.0, 0.0, 1.0], - [0.0, 0.0, 0.7071068, 0.7071068], - [0.7071068, 0.0, 0.0, 0.7071068], - [0.3826834, 0.0, 0.0, 0.9238795], - ], - device=device, - ) - - view.set_local_poses(new_translations, new_orientations) + xform_cache = UsdGeom.XformCache() + positions = [] + for i in range(num_envs): + prim = stage.GetPrimAtPath(f"/World/Parent_{i}") + tf = xform_cache.GetLocalToWorldTransform(prim) + t = tf.ExtractTranslation() + positions.append([float(t[0]), float(t[1]), float(t[2])]) + return torch.tensor(positions, dtype=torch.float32, device=device) - # Get local poses back - retrieved_translations, retrieved_orientations = view.get_local_poses() - # Verify they match - torch.testing.assert_close(retrieved_translations, new_translations, atol=1e-5, rtol=0) - try: - torch.testing.assert_close(retrieved_orientations, new_orientations, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(retrieved_orientations, -new_orientations, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_set_local_poses_only_translations(device, backend): - """Test setting only local translations.""" - _skip_if_backend_unavailable(backend, device) +def _set_parent_positions(positions, num_envs): + """Write parent Xform positions to USD.""" + from pxr import Sdf # noqa: PLC0415 stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create parent and children with specific orientations - sim_utils.create_prim("/World/Parent", "Xform", translation=(0.0, 0.0, 0.0), stage=stage) - initial_quat = (0.0, 0.0, 0.7071068, 0.7071068) - - for i in range(3): - sim_utils.create_prim( - f"/World/Parent/Child_{i}", - prim_type, - translation=(0.0, 0.0, 0.0), - orientation=initial_quat, - stage=stage, + with Sdf.ChangeBlock(): + for i in range(num_envs): + prim = stage.GetPrimAtPath(f"/World/Parent_{i}") + pos = positions[i] + prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(float(pos[0]), float(pos[1]), float(pos[2]))) + + +@pytest.fixture +def view_factory(): + """USD factory: parent Xform at PARENT_POS + child Xform at CHILD_OFFSET.""" + + def factory(num_envs: int, device: str) -> ViewBundle: + stage = sim_utils.get_current_stage() + for i in range(num_envs): + sim_utils.create_prim(f"/World/Parent_{i}", "Xform", translation=PARENT_POS, stage=stage) + sim_utils.create_prim(f"/World/Parent_{i}/Child", "Xform", translation=CHILD_OFFSET, stage=stage) + + view = FrameView("/World/Parent_.*/Child", device=device) + return ViewBundle( + view=view, + get_parent_pos=_get_parent_positions, + set_parent_pos=_set_parent_positions, + teardown=lambda: None, ) - # Create view - view = _create_view("/World/Parent/Child_.*", device=device, backend=backend) - - # Get initial orientations - _, initial_orientations = view.get_local_poses() + return factory - # Set only translations - new_translations = torch.tensor([[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]], device=device) - view.set_local_poses(translations=new_translations, orientations=None) - # Get poses back - retrieved_translations, retrieved_orientations = view.get_local_poses() - - # Translations should be updated - torch.testing.assert_close(retrieved_translations, new_translations, atol=1e-5, rtol=0) - - # Orientations should be unchanged - try: - torch.testing.assert_close(retrieved_orientations, initial_orientations, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(retrieved_orientations, -initial_orientations, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_set_scales(device, backend): - """Test setting scales in XformPrimView.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create prims - num_prims = 5 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Object_{i}", prim_type, scale=(1.0, 1.0, 1.0), stage=stage) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - # Set new scales - new_scales = torch.tensor( - [[2.0, 2.0, 2.0], [1.0, 2.0, 3.0], [0.5, 0.5, 0.5], [3.0, 1.0, 2.0], [1.5, 1.5, 1.5]], device=device - ) - - view.set_scales(new_scales) - - # Get scales back - retrieved_scales = view.get_scales() - - # Verify they match - torch.testing.assert_close(retrieved_scales, new_scales, atol=1e-5, rtol=0) +# ================================================================== +# USD-only: Visibility +# ================================================================== @pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_set_visibility(device): +def test_visibility_toggle(device): """Test toggling visibility multiple times.""" if device == "cuda" and not torch.cuda.is_available(): pytest.skip("CUDA not available") stage = sim_utils.get_current_stage() - - # Create prims num_prims = 3 for i in range(num_prims): sim_utils.create_prim(f"/World/Object_{i}", "Xform", stage=stage) - # Create view - view = XformPrimView("/World/Object_.*", device=device) + view = FrameView("/World/Object_.*", device=device) - # Initial state: all visible - visibility = view.get_visibility() - assert torch.all(visibility), "All should be visible initially" + assert torch.all(view.get_visibility()) - # Make all invisible view.set_visibility(torch.zeros(num_prims, dtype=torch.bool, device=device)) - visibility = view.get_visibility() - assert not torch.any(visibility), "All should be invisible" + assert not torch.any(view.get_visibility()) - # Make all visible again view.set_visibility(torch.ones(num_prims, dtype=torch.bool, device=device)) - visibility = view.get_visibility() - assert torch.all(visibility), "All should be visible again" - - # Toggle individual prims - view.set_visibility(torch.tensor([False], dtype=torch.bool, device=device), indices=[1]) - visibility = view.get_visibility() - assert visibility[0] and not visibility[1] and visibility[2], "Only middle prim should be invisible" - - -""" -Tests - Index Handling. -""" - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("index_type", ["list", "torch_tensor", "slice_none"]) -@pytest.mark.parametrize("method", ["world_poses", "local_poses", "scales", "visibility"]) -def test_index_types_get_methods(device, index_type, method): - """Test that getter methods work with different index types.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - stage = sim_utils.get_current_stage() - - # Create prims based on method type - num_prims = 10 - if method == "local_poses": - # Create parent and children for local poses - sim_utils.create_prim("/World/Parent", "Xform", translation=(10.0, 0.0, 0.0), stage=stage) - for i in range(num_prims): - sim_utils.create_prim( - f"/World/Parent/Child_{i}", "Xform", translation=(float(i), float(i) * 0.5, 0.0), stage=stage - ) - view = XformPrimView("/World/Parent/Child_.*", device=device) - elif method == "scales": - # Create prims with different scales - for i in range(num_prims): - scale = (1.0 + i * 0.5, 1.0 + i * 0.3, 1.0 + i * 0.2) - sim_utils.create_prim(f"/World/Object_{i}", "Xform", scale=scale, stage=stage) - view = XformPrimView("/World/Object_.*", device=device) - else: # world_poses - # Create prims with different positions - for i in range(num_prims): - sim_utils.create_prim(f"/World/Object_{i}", "Xform", translation=(float(i), 0.0, 0.0), stage=stage) - view = XformPrimView("/World/Object_.*", device=device) - - # Get all data as reference - if method == "world_poses": - all_data1, all_data2 = view.get_world_poses() - elif method == "local_poses": - all_data1, all_data2 = view.get_local_poses() - elif method == "scales": - all_data1 = view.get_scales() - all_data2 = None - else: # visibility - all_data1 = view.get_visibility() - all_data2 = None - - # Prepare indices - target_indices_base = [2, 5, 7] - indices, target_indices = _prepare_indices(index_type, target_indices_base, num_prims, device) - - # Get subset - if method == "world_poses": - subset_data1, subset_data2 = view.get_world_poses(indices=indices) # type: ignore[arg-type] - elif method == "local_poses": - subset_data1, subset_data2 = view.get_local_poses(indices=indices) # type: ignore[arg-type] - elif method == "scales": - subset_data1 = view.get_scales(indices=indices) # type: ignore[arg-type] - subset_data2 = None - else: # visibility - subset_data1 = view.get_visibility(indices=indices) # type: ignore[arg-type] - subset_data2 = None - - # Verify shapes - expected_count = len(target_indices) - if method == "visibility": - assert subset_data1.shape == (expected_count,) - else: - assert subset_data1.shape == (expected_count, 3) - if subset_data2 is not None: - assert subset_data2.shape == (expected_count, 4) - - # Verify values - target_indices_tensor = torch.tensor(target_indices, dtype=torch.int64, device=device) - torch.testing.assert_close(subset_data1, all_data1[target_indices_tensor], atol=1e-5, rtol=0) - if subset_data2 is not None and all_data2 is not None: - torch.testing.assert_close(subset_data2, all_data2[target_indices_tensor], atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("index_type", ["list", "torch_tensor", "slice_none"]) -@pytest.mark.parametrize("method", ["world_poses", "local_poses", "scales", "visibility"]) -def test_index_types_set_methods(device, index_type, method): - """Test that setter methods work with different index types.""" - if device == "cuda" and not torch.cuda.is_available(): - pytest.skip("CUDA not available") - - stage = sim_utils.get_current_stage() - - # Create prims based on method type - num_prims = 10 - if method == "local_poses": - # Create parent and children for local poses - sim_utils.create_prim("/World/Parent", "Xform", translation=(5.0, 5.0, 0.0), stage=stage) - for i in range(num_prims): - sim_utils.create_prim(f"/World/Parent/Child_{i}", "Xform", translation=(float(i), 0.0, 0.0), stage=stage) - view = XformPrimView("/World/Parent/Child_.*", device=device) - else: # world_poses or scales - for i in range(num_prims): - sim_utils.create_prim(f"/World/Object_{i}", "Xform", translation=(0.0, 0.0, 0.0), stage=stage) - view = XformPrimView("/World/Object_.*", device=device) - - # Get initial data - if method == "world_poses": - initial_data1, initial_data2 = view.get_world_poses() - elif method == "local_poses": - initial_data1, initial_data2 = view.get_local_poses() - elif method == "scales": - initial_data1 = view.get_scales() - initial_data2 = None - else: # visibility - initial_data1 = view.get_visibility() - initial_data2 = None - - # Prepare indices - target_indices_base = [2, 5, 7] - indices, target_indices = _prepare_indices(index_type, target_indices_base, num_prims, device) - - # Prepare new data - num_to_set = len(target_indices) - if method in ["world_poses", "local_poses"]: - new_data1 = torch.randn(num_to_set, 3, device=device) * 10.0 - new_data2 = torch.tensor([[0.0, 0.0, 0.0, 1.0]] * num_to_set, dtype=torch.float32, device=device) - elif method == "scales": - new_data1 = torch.rand(num_to_set, 3, device=device) * 2.0 + 0.5 - new_data2 = None - else: # visibility - # Set to False to test change (default is True) - new_data1 = torch.zeros(num_to_set, dtype=torch.bool, device=device) - new_data2 = None - - # Set data - if method == "world_poses": - view.set_world_poses(positions=new_data1, orientations=new_data2, indices=indices) # type: ignore[arg-type] - elif method == "local_poses": - view.set_local_poses(translations=new_data1, orientations=new_data2, indices=indices) # type: ignore[arg-type] - elif method == "scales": - view.set_scales(scales=new_data1, indices=indices) # type: ignore[arg-type] - else: # visibility - view.set_visibility(visibility=new_data1, indices=indices) # type: ignore[arg-type] - - # Get all data after update - if method == "world_poses": - updated_data1, updated_data2 = view.get_world_poses() - elif method == "local_poses": - updated_data1, updated_data2 = view.get_local_poses() - elif method == "scales": - updated_data1 = view.get_scales() - updated_data2 = None - else: # visibility - updated_data1 = view.get_visibility() - updated_data2 = None - - # Verify that specified indices were updated - for i, target_idx in enumerate(target_indices): - torch.testing.assert_close(updated_data1[target_idx], new_data1[i], atol=1e-5, rtol=0) - if new_data2 is not None and updated_data2 is not None: - try: - torch.testing.assert_close(updated_data2[target_idx], new_data2[i], atol=1e-5, rtol=0) - except AssertionError: - # Account for quaternion sign ambiguity - torch.testing.assert_close(updated_data2[target_idx], -new_data2[i], atol=1e-5, rtol=0) - - # Verify that other indices were NOT updated (only for non-slice(None) cases) - if index_type != "slice_none": - for i in range(num_prims): - if i not in target_indices: - torch.testing.assert_close(updated_data1[i], initial_data1[i], atol=1e-5, rtol=0) - if initial_data2 is not None and updated_data2 is not None: - try: - torch.testing.assert_close(updated_data2[i], initial_data2[i], atol=1e-5, rtol=0) - except AssertionError: - # Account for quaternion sign ambiguity - torch.testing.assert_close(updated_data2[i], -initial_data2[i], atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_indices_single_element(device, backend): - """Test with a single index.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create prims - num_prims = 5 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Object_{i}", prim_type, translation=(float(i), 0.0, 0.0), stage=stage) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - # Test with single index - indices = [3] - positions, orientations = view.get_world_poses(indices=indices) - - # Verify shapes - assert positions.shape == (1, 3) - assert orientations.shape == (1, 4) - - # Set pose for single index - new_position = torch.tensor([[100.0, 200.0, 300.0]], device=device) - view.set_world_poses(positions=new_position, indices=indices) - - # Verify it was set - retrieved_positions, _ = view.get_world_poses(indices=indices) - torch.testing.assert_close(retrieved_positions, new_position, atol=1e-5, rtol=0) - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_indices_out_of_order(device, backend): - """Test with indices provided in non-sequential order.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) + assert torch.all(view.get_visibility()) - # Create prims - num_prims = 10 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Object_{i}", prim_type, translation=(0.0, 0.0, 0.0), stage=stage) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - # Use out-of-order indices - indices = [7, 2, 9, 0, 5] - new_positions = torch.tensor( - [[7.0, 0.0, 0.0], [2.0, 0.0, 0.0], [9.0, 0.0, 0.0], [0.0, 0.0, 0.0], [5.0, 0.0, 0.0]], device=device + view.set_visibility( + torch.tensor([False], dtype=torch.bool, device=device), indices=wp.array([1], dtype=wp.int32, device=device) ) - - # Set poses with out-of-order indices - view.set_world_poses(positions=new_positions, indices=indices) - - # Get all poses - all_positions, _ = view.get_world_poses() - - # Verify each index got the correct value - expected_x_values = [0.0, 0.0, 2.0, 0.0, 0.0, 5.0, 0.0, 7.0, 0.0, 9.0] - for i in range(num_prims): - assert abs(all_positions[i, 0].item() - expected_x_values[i]) < 1e-5 - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -@pytest.mark.parametrize("backend", ["usd", "fabric"]) -def test_indices_with_only_positions_or_orientations(device, backend): - """Test indices work correctly when setting only positions or only orientations.""" - _skip_if_backend_unavailable(backend, device) - - stage = sim_utils.get_current_stage() - prim_type = _prim_type_for_backend(backend) - - # Create prims - num_prims = 5 - for i in range(num_prims): - sim_utils.create_prim( - f"/World/Object_{i}", - prim_type, - translation=(0.0, 0.0, 0.0), - orientation=(0.0, 0.0, 0.0, 1.0), - stage=stage, - ) - - # Create view - view = _create_view("/World/Object_.*", device=device, backend=backend) - - # Get initial poses - initial_positions, initial_orientations = view.get_world_poses() - - # Set only positions for specific indices - indices = [1, 3] - new_positions = torch.tensor([[10.0, 0.0, 0.0], [30.0, 0.0, 0.0]], device=device) - view.set_world_poses(positions=new_positions, orientations=None, indices=indices) - - # Get updated poses - updated_positions, updated_orientations = view.get_world_poses() - - # Verify positions updated for indices 1 and 3, others unchanged - torch.testing.assert_close(updated_positions[1], new_positions[0], atol=1e-5, rtol=0) - torch.testing.assert_close(updated_positions[3], new_positions[1], atol=1e-5, rtol=0) - torch.testing.assert_close(updated_positions[0], initial_positions[0], atol=1e-5, rtol=0) - - # Verify all orientations unchanged - try: - torch.testing.assert_close(updated_orientations, initial_orientations, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(updated_orientations, -initial_orientations, atol=1e-5, rtol=0) - - # Now set only orientations for different indices - indices2 = [0, 4] - new_orientations = torch.tensor([[0.0, 0.0, 0.7071068, 0.7071068], [0.7071068, 0.0, 0.0, 0.7071068]], device=device) - view.set_world_poses(positions=None, orientations=new_orientations, indices=indices2) - - # Get final poses - final_positions, final_orientations = view.get_world_poses() - - # Verify positions unchanged from previous step - torch.testing.assert_close(final_positions, updated_positions, atol=1e-5, rtol=0) - - # Verify orientations updated for indices 0 and 4 - try: - torch.testing.assert_close(final_orientations[0], new_orientations[0], atol=1e-5, rtol=0) - torch.testing.assert_close(final_orientations[4], new_orientations[1], atol=1e-5, rtol=0) - except AssertionError: - # Account for quaternion sign ambiguity - torch.testing.assert_close(final_orientations[0], -new_orientations[0], atol=1e-5, rtol=0) - torch.testing.assert_close(final_orientations[4], -new_orientations[1], atol=1e-5, rtol=0) + vis = view.get_visibility() + assert vis[0] and not vis[1] and vis[2] @pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_index_type_none_equivalent_to_all(device): - """Test that indices=None is equivalent to getting/setting all prims.""" +def test_visibility_parent_inheritance(device): + """Making a parent invisible hides all children.""" if device == "cuda" and not torch.cuda.is_available(): pytest.skip("CUDA not available") stage = sim_utils.get_current_stage() + sim_utils.create_prim("/World/Parent", "Xform", stage=stage) + for i in range(4): + sim_utils.create_prim(f"/World/Parent/Child_{i}", "Xform", stage=stage) - # Create prims - num_prims = 6 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Object_{i}", "Xform", translation=(float(i), 0.0, 0.0), stage=stage) - - # Create view - view = XformPrimView("/World/Object_.*", device=device) - - # Get poses with indices=None - pos_none, quat_none = view.get_world_poses(indices=None) - - # Get poses with no argument (default) - pos_default, quat_default = view.get_world_poses() - - # Get poses with slice(None) - pos_slice, quat_slice = view.get_world_poses(indices=slice(None)) # type: ignore[arg-type] - - # All should be equivalent - torch.testing.assert_close(pos_none, pos_default, atol=1e-10, rtol=0) - torch.testing.assert_close(quat_none, quat_default, atol=1e-10, rtol=0) - torch.testing.assert_close(pos_none, pos_slice, atol=1e-10, rtol=0) - torch.testing.assert_close(quat_none, quat_slice, atol=1e-10, rtol=0) - - # Test the same for set operations - new_positions = torch.randn(num_prims, 3, device=device) * 10.0 - new_orientations = torch.tensor([[0.0, 0.0, 0.0, 1.0]] * num_prims, dtype=torch.float32, device=device) - - # Set with indices=None - view.set_world_poses(positions=new_positions, orientations=new_orientations, indices=None) - pos_after_none, quat_after_none = view.get_world_poses() - - # Reset - view.set_world_poses(positions=torch.zeros(num_prims, 3, device=device), indices=None) + parent_view = FrameView("/World/Parent", device=device) + children_view = FrameView("/World/Parent/Child_.*", device=device) - # Set with slice(None) - view.set_world_poses( - positions=new_positions, - orientations=new_orientations, - indices=slice(None), # type: ignore[arg-type] - ) - pos_after_slice, quat_after_slice = view.get_world_poses() + parent_view.set_visibility(torch.tensor([False], dtype=torch.bool, device=device)) + assert not torch.any(children_view.get_visibility()) - # Should be equivalent - torch.testing.assert_close(pos_after_none, pos_after_slice, atol=1e-5, rtol=0) - torch.testing.assert_close(quat_after_none, quat_after_slice, atol=1e-5, rtol=0) + parent_view.set_visibility(torch.tensor([True], dtype=torch.bool, device=device)) + assert torch.all(children_view.get_visibility()) -""" -Tests - Integration. -""" +# ================================================================== +# USD-only: Prim ordering +# ================================================================== @pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_with_franka_robots(device): - """Test XformPrimView with real Franka robot USD assets.""" +def test_prim_ordering_follows_creation_order(device): + """Prims are returned in USD creation order (DFS), not alphabetical.""" if device == "cuda" and not torch.cuda.is_available(): pytest.skip("CUDA not available") stage = sim_utils.get_current_stage() + num_envs = 3 + for i in range(num_envs): + sim_utils.create_prim(f"/World/Env_{i}/Object_1", "Xform", stage=stage) + sim_utils.create_prim(f"/World/Env_{i}/Object_0", "Xform", stage=stage) + sim_utils.create_prim(f"/World/Env_{i}/Object_A", "Xform", stage=stage) - # Load Franka robot assets - franka_usd_path = f"{ISAAC_NUCLEUS_DIR}/Robots/FrankaRobotics/FrankaPanda/franka.usd" - - # Add two Franka robots to the stage - sim_utils.create_prim("/World/Franka_1", "Xform", usd_path=franka_usd_path, stage=stage) - sim_utils.create_prim("/World/Franka_2", "Xform", usd_path=franka_usd_path, stage=stage) - - # Create view for both Frankas - frankas_view = XformPrimView("/World/Franka_.*", device=device) - - # Verify count - assert frankas_view.count == 2 + view = FrameView("/World/Env_.*/Object_.*", device=device) + expected = [] + for i in range(num_envs): + expected += [f"/World/Env_{i}/Object_1", f"/World/Env_{i}/Object_0", f"/World/Env_{i}/Object_A"] - # Get initial world poses (should be at origin) - initial_positions, initial_orientations = frankas_view.get_world_poses() + assert view.prim_paths == expected - # Verify initial positions are at origin - expected_initial_positions = torch.zeros(2, 3, device=device) - torch.testing.assert_close(initial_positions, expected_initial_positions, atol=1e-5, rtol=0) - # Verify initial orientations are identity - expected_initial_orientations = torch.tensor([[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0]], device=device) - try: - torch.testing.assert_close(initial_orientations, expected_initial_orientations, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(initial_orientations, -expected_initial_orientations, atol=1e-5, rtol=0) - - # Set new world poses - new_positions = torch.tensor([[10.0, 10.0, 0.0], [-40.0, -40.0, 0.0]], device=device) - # 90° rotation around Z axis for first, -90° for second - new_orientations = torch.tensor( - [[0.0, 0.0, 0.7071068, 0.7071068], [0.0, 0.0, -0.7071068, 0.7071068]], device=device - ) - - frankas_view.set_world_poses(positions=new_positions, orientations=new_orientations) - - # Get poses back and verify - retrieved_positions, retrieved_orientations = frankas_view.get_world_poses() - - torch.testing.assert_close(retrieved_positions, new_positions, atol=1e-5, rtol=0) - try: - torch.testing.assert_close(retrieved_orientations, new_orientations, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(retrieved_orientations, -new_orientations, atol=1e-5, rtol=0) +# ================================================================== +# USD-only: xformOp standardization +# ================================================================== @pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_with_nested_targets(device): - """Test with nested frame/target structure similar to Isaac Sim tests.""" +def test_standardize_transform_op(device): + """FrameView standardizes a prim with xformOp:transform to translate/orient/scale.""" if device == "cuda" and not torch.cuda.is_available(): pytest.skip("CUDA not available") - stage = sim_utils.get_current_stage() - - # Create frames and targets - for i in range(1, 4): - sim_utils.create_prim(f"/World/Frame_{i}", "Xform", stage=stage) - sim_utils.create_prim(f"/World/Frame_{i}/Target", "Xform", stage=stage) - - # Create views - frames_view = XformPrimView("/World/Frame_.*", device=device) - targets_view = XformPrimView("/World/Frame_.*/Target", device=device) - - assert frames_view.count == 3 - assert targets_view.count == 3 + expected_pos = (3.0, -1.0, 0.5) + matrix = Gf.Matrix4d(1.0) + matrix.SetTranslateOnly(Gf.Vec3d(*expected_pos)) - # Set local poses for frames - frame_translations = torch.tensor([[0.0, 0.0, 0.0], [0.0, 10.0, 5.0], [0.0, 3.0, 5.0]], device=device) - frames_view.set_local_poses(translations=frame_translations) + stage = sim_utils.get_current_stage() + prim = stage.DefinePrim("/World/TransformPrim", "Xform") + UsdGeom.Xformable(prim).AddTransformOp().Set(matrix) - # Set local poses for targets - target_translations = torch.tensor([[0.0, 20.0, 10.0], [0.0, 30.0, 20.0], [0.0, 50.0, 10.0]], device=device) - targets_view.set_local_poses(translations=target_translations) + view = FrameView("/World/TransformPrim", device=device) + assert sim_utils.validate_standard_xform_ops(view.prims[0]) - # Get world poses of targets - world_positions, _ = targets_view.get_world_poses() + ordered_ops = UsdGeom.Xformable(view.prims[0]).GetOrderedXformOps() + op_names = [op.GetOpName() for op in ordered_ops] + assert op_names == ["xformOp:translate", "xformOp:orient", "xformOp:scale"] + assert ordered_ops[0].Get() == Gf.Vec3d(*expected_pos) - # Expected world positions are frame_translation + target_translation - expected_positions = torch.tensor([[0.0, 20.0, 10.0], [0.0, 40.0, 25.0], [0.0, 53.0, 15.0]], device=device) - torch.testing.assert_close(world_positions, expected_positions, atol=1e-5, rtol=0) +# ================================================================== +# USD-only: Nested hierarchy (frame + target) +# ================================================================== @pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_visibility_with_hierarchy(device): - """Test visibility with parent-child hierarchy and inheritance.""" +def test_nested_hierarchy_world_poses(device): + """World pose of nested child == sum of parent + child translations.""" if device == "cuda" and not torch.cuda.is_available(): pytest.skip("CUDA not available") stage = sim_utils.get_current_stage() + frame_positions = [(0.0, 0.0, 0.0), (0.0, 10.0, 5.0), (0.0, 3.0, 5.0)] + target_positions = [(0.0, 20.0, 10.0), (0.0, 30.0, 20.0), (0.0, 50.0, 10.0)] - # Create parent and children - sim_utils.create_prim("/World/Parent", "Xform", stage=stage) - - num_children = 4 - for i in range(num_children): - sim_utils.create_prim(f"/World/Parent/Child_{i}", "Xform", stage=stage) - - # Create views for both parent and children - parent_view = XformPrimView("/World/Parent", device=device) - children_view = XformPrimView("/World/Parent/Child_.*", device=device) - - # Verify parent and all children are visible initially - parent_visibility = parent_view.get_visibility() - children_visibility = children_view.get_visibility() - assert parent_visibility[0], "Parent should be visible initially" - assert torch.all(children_visibility), "All children should be visible initially" - - # Make some children invisible directly - new_visibility = torch.tensor([True, False, True, False], dtype=torch.bool, device=device) - children_view.set_visibility(new_visibility) - - # Verify the visibility changes - retrieved_visibility = children_view.get_visibility() - torch.testing.assert_close(retrieved_visibility, new_visibility) - - # Make all children visible again - children_view.set_visibility(torch.ones(num_children, dtype=torch.bool, device=device)) - all_visible = children_view.get_visibility() - assert torch.all(all_visible), "All children should be visible again" - - # Now test parent visibility inheritance: - # Make parent invisible - parent_view.set_visibility(torch.tensor([False], dtype=torch.bool, device=device)) - - # Verify parent is invisible - parent_visibility = parent_view.get_visibility() - assert not parent_visibility[0], "Parent should be invisible" + for i in range(3): + sim_utils.create_prim(f"/World/Frame_{i}", "Xform", translation=frame_positions[i], stage=stage) + sim_utils.create_prim(f"/World/Frame_{i}/Target", "Xform", translation=target_positions[i], stage=stage) - # Verify children are also invisible (due to parent being invisible) - children_visibility = children_view.get_visibility() - assert not torch.any(children_visibility), "All children should be invisible when parent is invisible" + frames_view = FrameView("/World/Frame_.*", device=device) + targets_view = FrameView("/World/Frame_.*/Target", device=device) - # Make parent visible again - parent_view.set_visibility(torch.tensor([True], dtype=torch.bool, device=device)) + frames_view.set_local_poses(translations=torch.tensor(frame_positions, device=device)) + targets_view.set_local_poses(translations=torch.tensor(target_positions, device=device)) - # Verify parent is visible - parent_visibility = parent_view.get_visibility() - assert parent_visibility[0], "Parent should be visible again" - - # Verify children are also visible again - children_visibility = children_view.get_visibility() - assert torch.all(children_visibility), "All children should be visible again when parent is visible" + world_pos = wp.to_torch(targets_view.get_world_poses()[0]) + expected = torch.tensor( + [[f[j] + t[j] for j in range(3)] for f, t in zip(frame_positions, target_positions)], + device=device, + ) + torch.testing.assert_close(world_pos, expected, atol=1e-5, rtol=0) -""" -Tests - Comparison with Isaac Sim Implementation. -""" +# ================================================================== +# USD-only: Comparison with Isaac Sim +# ================================================================== def test_compare_get_world_poses_with_isaacsim(): """Compare get_world_poses with Isaac Sim's implementation.""" - stage = sim_utils.get_current_stage() - - # Check if Isaac Sim is available if _IsaacSimXformPrimView is None: pytest.skip("Isaac Sim is not available") - # Create prims with various poses + stage = sim_utils.get_current_stage() num_prims = 10 for i in range(num_prims): pos = (i * 2.0, i * 0.5, i * 1.5) - # Vary orientations - if i % 3 == 0: - quat = (0.0, 0.0, 0.0, 1.0) # Identity - elif i % 3 == 1: - quat = (0.0, 0.0, 0.7071068, 0.7071068) # 90 deg around Z - else: - quat = (0.7071068, 0.0, 0.0, 0.7071068) # 90 deg around X + quat = (0.0, 0.0, 0.0, 1.0) if i % 2 == 0 else (0.0, 0.0, 0.7071068, 0.7071068) sim_utils.create_prim(f"/World/Env_{i}/Object", "Xform", translation=pos, orientation=quat, stage=stage) pattern = "/World/Env_.*/Object" - - # Create both views - isaaclab_view = XformPrimView(pattern, device="cpu") + isaaclab_view = FrameView(pattern, device="cpu") isaacsim_view = _IsaacSimXformPrimView(pattern, reset_xform_properties=False) - # Get world poses from both - isaaclab_pos, isaaclab_quat = isaaclab_view.get_world_poses() # xyzw - isaacsim_pos, isaacsim_quat = isaacsim_view.get_world_poses() # wxyz - - # Convert Isaac Sim results to torch tensors if needed + isaaclab_pos = wp.to_torch(isaaclab_view.get_world_poses()[0]) + isaacsim_pos, isaacsim_quat = isaacsim_view.get_world_poses() if not isinstance(isaacsim_pos, torch.Tensor): isaacsim_pos = torch.tensor(isaacsim_pos, dtype=torch.float32) - if not isinstance(isaacsim_quat, torch.Tensor): - isaacsim_quat = torch.tensor(isaacsim_quat, dtype=torch.float32).roll(-1, dims=1) - # Compare results torch.testing.assert_close(isaaclab_pos, isaacsim_pos, atol=1e-5, rtol=0) - # Compare quaternions (account for sign ambiguity) - try: - torch.testing.assert_close(isaaclab_quat, isaacsim_quat, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(isaaclab_quat, -isaacsim_quat, atol=1e-5, rtol=0) - - -def test_compare_set_world_poses_with_isaacsim(): - """Compare set_world_poses with Isaac Sim's implementation.""" - stage = sim_utils.get_current_stage() - - # Check if Isaac Sim is available - if _IsaacSimXformPrimView is None: - pytest.skip("Isaac Sim is not available") - - # Create prims - num_prims = 8 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Env_{i}/Object", "Xform", translation=(0.0, 0.0, 0.0), stage=stage) - - pattern = "/World/Env_.*/Object" - - # Create both views - isaaclab_view = XformPrimView(pattern, device="cpu") - isaacsim_view = _IsaacSimXformPrimView(pattern, reset_xform_properties=False) - - # Generate new poses - new_positions = torch.randn(num_prims, 3) * 10.0 - new_orientations = torch.tensor([[0.0, 0.0, 0.0, 1.0]] * num_prims, dtype=torch.float32) - - # Set poses using both implementations - isaaclab_view.set_world_poses(new_positions.clone(), new_orientations.clone()) # xyzw - isaacsim_view.set_world_poses(new_positions.clone(), new_orientations.clone().roll(1, dims=1)) # wxyz - - # Get poses back from both - isaaclab_pos, isaaclab_quat = isaaclab_view.get_world_poses() # xyzw - isaacsim_pos, isaacsim_quat = isaacsim_view.get_world_poses() # wxyz - - # Convert Isaac Sim results to torch tensors if needed - if not isinstance(isaacsim_pos, torch.Tensor): - isaacsim_pos = torch.tensor(isaacsim_pos, dtype=torch.float32) - if not isinstance(isaacsim_quat, torch.Tensor): - isaacsim_quat = torch.tensor(isaacsim_quat, dtype=torch.float32).roll(-1, dims=1) - - # Compare results - both implementations should produce the same world poses - torch.testing.assert_close(isaaclab_pos, isaacsim_pos, atol=1e-4, rtol=0) - try: - torch.testing.assert_close(isaaclab_quat, isaacsim_quat, atol=1e-4, rtol=0) - except AssertionError: - torch.testing.assert_close(isaaclab_quat, -isaacsim_quat, atol=1e-4, rtol=0) - - -def test_compare_get_local_poses_with_isaacsim(): - """Compare get_local_poses with Isaac Sim's implementation.""" - stage = sim_utils.get_current_stage() - - # Check if Isaac Sim is available - if _IsaacSimXformPrimView is None: - pytest.skip("Isaac Sim is not available") - - # Create hierarchical prims - num_prims = 5 - for i in range(num_prims): - # Create parent - sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 5.0, 0.0, 0.0), stage=stage) - # Create child with local pose - local_pos = (1.0, float(i), 0.0) - local_quat = (0.0, 0.0, 0.0, 1.0) if i % 2 == 0 else (0.0, 0.0, 0.7071068, 0.7071068) - sim_utils.create_prim( - f"/World/Env_{i}/Object", "Xform", translation=local_pos, orientation=local_quat, stage=stage - ) - - pattern = "/World/Env_.*/Object" - - # Create both views - isaaclab_view = XformPrimView(pattern, device="cpu") - isaacsim_view = _IsaacSimXformPrimView(pattern, reset_xform_properties=False) - - # Get local poses from both - isaaclab_trans, isaaclab_quat = isaaclab_view.get_local_poses() - isaacsim_trans, isaacsim_quat = isaacsim_view.get_local_poses() - - # Convert Isaac Sim results to torch tensors if needed - if not isinstance(isaacsim_trans, torch.Tensor): - isaacsim_trans = torch.tensor(isaacsim_trans, dtype=torch.float32) - if not isinstance(isaacsim_quat, torch.Tensor): - isaacsim_quat = torch.tensor(isaacsim_quat, dtype=torch.float32).roll(-1, dims=1) - - # Compare results - torch.testing.assert_close(isaaclab_trans, isaacsim_trans, atol=1e-5, rtol=0) - try: - torch.testing.assert_close(isaaclab_quat, isaacsim_quat, atol=1e-5, rtol=0) - except AssertionError: - torch.testing.assert_close(isaaclab_quat, -isaacsim_quat, atol=1e-5, rtol=0) - - -def test_compare_set_local_poses_with_isaacsim(): - """Compare set_local_poses with Isaac Sim's implementation.""" - stage = sim_utils.get_current_stage() - - # Check if Isaac Sim is available - if _IsaacSimXformPrimView is None: - pytest.skip("Isaac Sim is not available") - - # Create hierarchical prims - num_prims = 6 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Env_{i}", "Xform", translation=(i * 3.0, 0.0, 0.0), stage=stage) - sim_utils.create_prim(f"/World/Env_{i}/Object", "Xform", translation=(0.0, 0.0, 0.0), stage=stage) - - pattern = "/World/Env_.*/Object" - - # Create both views - isaaclab_view = XformPrimView(pattern, device="cpu") - isaacsim_view = _IsaacSimXformPrimView(pattern, reset_xform_properties=False) - - # Generate new local poses - new_translations = torch.randn(num_prims, 3) * 5.0 - new_orientations = torch.tensor( - [[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.7071068, 0.7071068]] * (num_prims // 2), dtype=torch.float32 - ) - # Set local poses using both implementations - isaaclab_view.set_local_poses(new_translations.clone(), new_orientations.clone()) - isaacsim_view.set_local_poses(new_translations.clone(), new_orientations.clone().roll(1, dims=1)) - - # Get local poses back from both - isaaclab_trans, isaaclab_quat = isaaclab_view.get_local_poses() - isaacsim_trans, isaacsim_quat = isaacsim_view.get_local_poses() - - # Convert Isaac Sim results to torch tensors if needed - if not isinstance(isaacsim_trans, torch.Tensor): - isaacsim_trans = torch.tensor(isaacsim_trans, dtype=torch.float32) - if not isinstance(isaacsim_quat, torch.Tensor): - isaacsim_quat = torch.tensor(isaacsim_quat, dtype=torch.float32).roll(-1, dims=1) - - # Compare results - torch.testing.assert_close(isaaclab_trans, isaacsim_trans, atol=1e-4, rtol=0) - try: - torch.testing.assert_close(isaaclab_quat, isaacsim_quat, atol=1e-4, rtol=0) - except AssertionError: - torch.testing.assert_close(isaaclab_quat, -isaacsim_quat, atol=1e-4, rtol=0) - - -""" -Tests - Fabric Operations. -""" - - -@pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_fabric_initialization(device): - """Test XformPrimView initialization with Fabric enabled.""" - _skip_if_backend_unavailable("fabric", device) - - stage = sim_utils.get_current_stage() - - # Create camera prims (Boundable prims that support Fabric) - num_prims = 5 - for i in range(num_prims): - sim_utils.create_prim(f"/World/Cam_{i}", "Camera", translation=(i * 1.0, 0.0, 1.0), stage=stage) - - # Create view with Fabric enabled - view = _create_view("/World/Cam_.*", device=device, backend="fabric") - - # Verify properties - assert view.count == num_prims - assert view.device == device - assert len(view.prims) == num_prims +# ================================================================== +# USD-only: Franka integration +# ================================================================== @pytest.mark.parametrize("device", ["cpu", "cuda"]) -def test_fabric_usd_consistency(device): - """Test that Fabric round-trip (write→read) is consistent, matching Isaac Sim's design. - - Note: This does NOT test Fabric vs USD reads on initialization, as Fabric is designed - for write-first workflows. Instead, it tests that: - 1. Fabric write→read round-trip works correctly - 2. This matches Isaac Sim's Fabric behavior - """ - _skip_if_backend_unavailable("fabric", device) +def test_with_franka_robots(device): + """Verify FrameView works with real Franka robot USD assets.""" + if device == "cuda" and not torch.cuda.is_available(): + pytest.skip("CUDA not available") stage = sim_utils.get_current_stage() + franka_usd_path = f"{ISAAC_NUCLEUS_DIR}/Robots/FrankaRobotics/FrankaPanda/franka.usd" - # Create prims - num_prims = 5 - for i in range(num_prims): - sim_utils.create_prim( - f"/World/Cam_{i}", - "Camera", - translation=(i * 1.0, 2.0, 3.0), - orientation=(0.0, 0.0, 0.7071068, 0.7071068), - stage=stage, - ) - - # Create Fabric view - view_fabric = _create_view("/World/Cam_.*", device=device, backend="fabric") - - # Test Fabric write→read round-trip (Isaac Sim's intended workflow) - # Initialize Fabric state by WRITING first - init_positions = torch.zeros((num_prims, 3), dtype=torch.float32, device=device) - init_positions[:, 0] = torch.arange(num_prims, dtype=torch.float32, device=device) - init_positions[:, 1] = 2.0 - init_positions[:, 2] = 3.0 - init_orientations = torch.tensor([[0.0, 0.0, 0.7071068, 0.7071068]] * num_prims, dtype=torch.float32, device=device) - - view_fabric.set_world_poses(init_positions, init_orientations) + sim_utils.create_prim("/World/Franka_1", "Xform", usd_path=franka_usd_path, stage=stage) + sim_utils.create_prim("/World/Franka_2", "Xform", usd_path=franka_usd_path, stage=stage) - # Read back from Fabric (should match what we wrote) - pos_fabric, quat_fabric = view_fabric.get_world_poses() - torch.testing.assert_close(pos_fabric, init_positions, atol=1e-4, rtol=0) - torch.testing.assert_close(quat_fabric, init_orientations, atol=1e-4, rtol=0) + view = FrameView("/World/Franka_.*", device=device) + assert view.count == 2 - # Test another round-trip with different values - new_positions = torch.rand((num_prims, 3), dtype=torch.float32, device=device) * 10.0 - new_orientations = torch.tensor([[0.0, 0.0, 0.0, 1.0]] * num_prims, dtype=torch.float32, device=device) + positions = wp.to_torch(view.get_world_poses()[0]) + torch.testing.assert_close(positions, torch.zeros(2, 3, device=device), atol=1e-5, rtol=0) - view_fabric.set_world_poses(new_positions, new_orientations) + new_pos = torch.tensor([[10.0, 10.0, 0.0], [-40.0, -40.0, 0.0]], device=device) + new_quat = torch.tensor([[0.0, 0.0, 0.7071068, 0.7071068], [0.0, 0.0, -0.7071068, 0.7071068]], device=device) + view.set_world_poses(positions=new_pos, orientations=new_quat) - # Read back from Fabric (should match) - pos_fabric_after, quat_fabric_after = view_fabric.get_world_poses() - torch.testing.assert_close(pos_fabric_after, new_positions, atol=1e-4, rtol=0) - torch.testing.assert_close(quat_fabric_after, new_orientations, atol=1e-4, rtol=0) + ret_pos = wp.to_torch(view.get_world_poses()[0]) + torch.testing.assert_close(ret_pos, new_pos, atol=1e-5, rtol=0) diff --git a/source/isaaclab/test/terrains/check_terrain_importer.py b/source/isaaclab/test/terrains/check_terrain_importer.py index 519f84fc2743..c024d33bb5f1 100644 --- a/source/isaaclab/test/terrains/check_terrain_importer.py +++ b/source/isaaclab/test/terrains/check_terrain_importer.py @@ -153,8 +153,8 @@ def main(): physics_scene_path, "/World/collisions", prim_paths=envs_prim_paths, global_paths=["/World/ground"] ) - # Set ball positions over terrain origins using XformPrimView (before simulation starts) - xform_view = sim_utils.XformPrimView("/World/envs/env_.*/ball") + # Set ball positions over terrain origins using FrameView (before simulation starts) + xform_view = sim_utils.FrameView("/World/envs/env_.*/ball") # cache initial state of the balls ball_initial_positions = terrain_importer.env_origins.clone() ball_initial_positions[:, 2] += 5.0 diff --git a/source/isaaclab/test/terrains/test_terrain_importer.py b/source/isaaclab/test/terrains/test_terrain_importer.py index 3951296e978a..8842c6df673b 100644 --- a/source/isaaclab/test/terrains/test_terrain_importer.py +++ b/source/isaaclab/test/terrains/test_terrain_importer.py @@ -327,8 +327,8 @@ def _populate_scene(sim: SimulationContext, num_balls: int = 2048, geom_sphere: ) # Set ball positions over terrain origins - # Create a view over all the balls using Isaac Lab's XformPrimView - ball_view = sim_utils.XformPrimView("/World/envs/env_.*/ball") + # Create a view over all the balls using Isaac Lab's FrameView + ball_view = sim_utils.FrameView("/World/envs/env_.*/ball") # cache initial state of the balls ball_initial_positions = terrain_importer.env_origins.clone() ball_initial_positions[:, 2] += 5.0 diff --git a/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py b/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py index 88a2249bafa0..5ea9a373fb3b 100644 --- a/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py +++ b/source/isaaclab_contrib/test/sensors/test_visuotactile_sensor.py @@ -307,7 +307,8 @@ def test_sensor_cam_set_wrong_prim(setup_tactile_cam): sim.reset() robot.update(dt) sensor.update(dt) - assert "Could not find prim with path" in str(excinfo.value) + err_msg = str(excinfo.value) + assert "Could not find prim with path" in err_msg or "does not match the number of environments" in err_msg @pytest.mark.isaacsim_ci diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py index 79cf307c9cb3..2a05b5f70096 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py @@ -10,7 +10,7 @@ import warp as wp import isaaclab.utils.math as math_utils -from isaaclab.sim.views import XformPrimView +from isaaclab.sim.views import FrameView from .occupancy_map_utils import OccupancyMap, intersect_occupancy_maps from .transform_utils import transform_mul @@ -101,19 +101,21 @@ def __init__(self, scene, entity_name: str): self.scene = scene self.entity_name = entity_name - def _get_xform_view(self) -> XformPrimView: - """Return the XformPrimView for this asset, refreshing it if prims were not yet cloned.""" + def _get_xform_view(self) -> FrameView: + """Return the FrameView for this asset, refreshing it if prims were not yet cloned.""" xform_prim = self.scene[self.entity_name] if xform_prim.count == 0: # The view was created before environment cloning; rebuild it now that prims exist. - xform_prim = XformPrimView(xform_prim._prim_path, device=xform_prim.device) + xform_prim = FrameView(xform_prim._prim_path, device=xform_prim.device) self.scene.extras[self.entity_name] = xform_prim return xform_prim def get_pose(self): """Get the 3D pose of the entity.""" xform_prim = self._get_xform_view() - position, orientation = xform_prim.get_world_poses() + pos_wp, ori_wp = xform_prim.get_world_poses() + position = wp.to_torch(pos_wp) + orientation = wp.to_torch(ori_wp) pose = torch.cat([position, orientation], dim=-1) return pose @@ -122,7 +124,7 @@ def set_pose(self, pose: torch.Tensor): xform_prim = self._get_xform_view() position = pose[..., :3] orientation = pose[..., 3:] - xform_prim.set_world_poses(position, orientation, None) + xform_prim.set_world_poses(wp.from_torch(position.contiguous()), wp.from_torch(orientation.contiguous()), None) class RelativePose(HasPose): diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 3639311a8a91..8a95afe963a0 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.19" +version = "0.5.20" # Description title = "Newton simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index f8f670baf455..3e6cdd8cd115 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,22 @@ Changelog --------- +0.5.20 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_newton.sim.views.XformPrimView` providing the Newton + backend implementation for xform prim views. + +Changed +^^^^^^^ + +* Renamed :class:`~isaaclab_newton.sim.views.NewtonSiteXformPrimView` to + :class:`~isaaclab_newton.sim.views.NewtonSiteFrameView`. Old name is kept as a deprecated alias. + + 0.5.19 (2026-04-22) ~~~~~~~~~~~~~~~~~~~ @@ -129,10 +145,6 @@ Fixed so articulation write methods trigger ``eval_fk`` before the next ``collide()``. - -0.5.9 (2026-03-16) -~~~~~~~~~~~~~~~~~~ - Fixed ^^^^^ diff --git a/source/isaaclab_newton/isaaclab_newton/sim/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/__init__.py new file mode 100644 index 000000000000..b4646aabbd0a --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Newton simulation utilities.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi new file mode 100644 index 000000000000..aac4c8327ccb --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/__init__.pyi @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "views", +] + +from . import views diff --git a/source/isaaclab_newton/isaaclab_newton/sim/views/__init__.py b/source/isaaclab_newton/isaaclab_newton/sim/views/__init__.py new file mode 100644 index 000000000000..44e8303bcedf --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/views/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Newton simulation views.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_newton/isaaclab_newton/sim/views/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/sim/views/__init__.pyi new file mode 100644 index 000000000000..433dfc1e8b6a --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/views/__init__.pyi @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "NewtonSiteFrameView", +] + +from .newton_site_frame_view import NewtonSiteFrameView diff --git a/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py new file mode 100644 index 000000000000..e4f2285cb528 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/sim/views/newton_site_frame_view.py @@ -0,0 +1,939 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Newton-backed FrameView — Warp-native, GPU-resident pose queries.""" + +from __future__ import annotations + +import logging + +import warp as wp + +from pxr import Gf, Usd, UsdGeom + +import isaaclab.sim as sim_utils +from isaaclab.physics import PhysicsEvent +from isaaclab.sim.views.base_frame_view import BaseFrameView + +from isaaclab_newton.physics.newton_manager import NewtonManager + +logger = logging.getLogger(__name__) + +WORLD_BODY_INDEX = -1 + + +# ------------------------------------------------------------------ +# Warp kernels +# ------------------------------------------------------------------ + + +@wp.kernel +def _compute_site_world_transforms( + body_q: wp.array(dtype=wp.transformf), + site_body: wp.array(dtype=wp.int32), + site_local: wp.array(dtype=wp.transformf), + out_pos: wp.array(dtype=wp.vec3f), + out_quat: wp.array(dtype=wp.vec4f), +): + """Compute world-space transforms for every site in the view. + + For each site *i*, computes ``world = body_q[site_body[i]] * site_local[i]`` + and splits the result into position and quaternion outputs. When + ``site_body[i] == -1`` the site is world-attached and ``site_local[i]`` is + returned directly. + + Args: + body_q: Rigid-body world transforms from the Newton state, shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + A value of ``-1`` indicates a world-attached site. + site_local: Per-site local offset relative to its parent body, shape ``[num_sites]``. + out_pos: Output world positions [m], shape ``[num_sites]``. + out_quat: Output world orientations as ``(qx, qy, qz, qw)``, shape ``[num_sites]``. + """ + i = wp.tid() + bid = site_body[i] + if bid == -1: + world = site_local[i] + else: + world = wp.transform_multiply(body_q[bid], site_local[i]) + out_pos[i] = wp.transform_get_translation(world) + q = wp.transform_get_rotation(world) + out_quat[i] = wp.vec4f(q[0], q[1], q[2], q[3]) + + +@wp.kernel +def _compute_site_world_transforms_indexed( + body_q: wp.array(dtype=wp.transformf), + site_body: wp.array(dtype=wp.int32), + site_local: wp.array(dtype=wp.transformf), + indices: wp.array(dtype=wp.int32), + out_pos: wp.array(dtype=wp.vec3f), + out_quat: wp.array(dtype=wp.vec4f), +): + """Indexed variant of :func:`_compute_site_world_transforms`. + + Only computes world transforms for the subset of sites selected by + ``indices``. Thread *i* reads ``indices[i]`` to obtain the site index, + then writes the result to ``out_pos[i]`` / ``out_quat[i]``. + + Args: + body_q: Rigid-body world transforms from the Newton state, shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + site_local: Per-site local offset relative to its parent body, shape ``[num_sites]``. + indices: Site indices to query, shape ``[M]``. + out_pos: Output world positions [m], shape ``[M]``. + out_quat: Output world orientations as ``(qx, qy, qz, qw)``, shape ``[M]``. + """ + i = wp.tid() + si = indices[i] + bid = site_body[si] + if bid == -1: + world = site_local[si] + else: + world = wp.transform_multiply(body_q[bid], site_local[si]) + out_pos[i] = wp.transform_get_translation(world) + q = wp.transform_get_rotation(world) + out_quat[i] = wp.vec4f(q[0], q[1], q[2], q[3]) + + +@wp.kernel +def _gather_scales( + shape_scale: wp.array(dtype=wp.vec3f), + shape_body: wp.array(dtype=wp.int32), + site_body: wp.array(dtype=wp.int32), + num_shapes: wp.int32, + out_scales: wp.array(dtype=wp.vec3f), +): + """Gather per-site scales from collision shapes on the same body. + + For each site *i*, linearly scans all shapes to find the first one whose + ``shape_body`` matches ``site_body[i]`` and copies its scale. Falls back + to ``(1, 1, 1)`` if no shape is found on that body. + + Args: + shape_scale: Per-shape scale vectors from the Newton model, shape ``[num_shapes]``. + shape_body: Per-shape parent body index, shape ``[num_shapes]``. + site_body: Per-site body index, shape ``[num_sites]``. + num_shapes: Total number of shapes in the model. + out_scales: Output scale per site, shape ``[num_sites]``. + """ + i = wp.tid() + bid = site_body[i] + found = int(0) + for s in range(num_shapes): + if shape_body[s] == bid and found == 0: + out_scales[i] = shape_scale[s] + found = 1 + if found == 0: + out_scales[i] = wp.vec3f(1.0, 1.0, 1.0) + + +@wp.kernel +def _gather_scales_indexed( + shape_scale: wp.array(dtype=wp.vec3f), + shape_body: wp.array(dtype=wp.int32), + site_body: wp.array(dtype=wp.int32), + indices: wp.array(dtype=wp.int32), + num_shapes: wp.int32, + out_scales: wp.array(dtype=wp.vec3f), +): + """Indexed variant of :func:`_gather_scales`. + + Args: + shape_scale: Per-shape scale vectors from the Newton model, shape ``[num_shapes]``. + shape_body: Per-shape parent body index, shape ``[num_shapes]``. + site_body: Per-site body index, shape ``[num_sites]``. + indices: Site indices to query, shape ``[M]``. + num_shapes: Total number of shapes in the model. + out_scales: Output scale per queried site, shape ``[M]``. + """ + i = wp.tid() + si = indices[i] + bid = site_body[si] + found = int(0) + for s in range(num_shapes): + if shape_body[s] == bid and found == 0: + out_scales[i] = shape_scale[s] + found = 1 + if found == 0: + out_scales[i] = wp.vec3f(1.0, 1.0, 1.0) + + +@wp.kernel +def _scatter_scales( + site_body: wp.array(dtype=wp.int32), + new_scales: wp.array(dtype=wp.vec3f), + shape_body: wp.array(dtype=wp.int32), + num_shapes: wp.int32, + shape_scale: wp.array(dtype=wp.vec3f), +): + """Scatter per-site scales to all collision shapes on the same body. + + For each site *i*, writes ``new_scales[i]`` to every shape whose + ``shape_body`` matches ``site_body[i]``. Multiple shapes on the same + body all receive the same scale. + + Args: + site_body: Per-site body index, shape ``[num_sites]``. + new_scales: New scale to apply per site, shape ``[num_sites]``. + shape_body: Per-shape parent body index, shape ``[num_shapes]``. + num_shapes: Total number of shapes in the model. + shape_scale: Per-shape scale vectors to write into (modified in-place), + shape ``[num_shapes]``. + """ + i = wp.tid() + bid = site_body[i] + for s in range(num_shapes): + if shape_body[s] == bid: + shape_scale[s] = new_scales[i] + + +@wp.kernel +def _scatter_scales_indexed( + site_body: wp.array(dtype=wp.int32), + indices: wp.array(dtype=wp.int32), + new_scales: wp.array(dtype=wp.vec3f), + shape_body: wp.array(dtype=wp.int32), + num_shapes: wp.int32, + shape_scale: wp.array(dtype=wp.vec3f), +): + """Indexed variant of :func:`_scatter_scales`. + + Args: + site_body: Per-site body index, shape ``[num_sites]``. + indices: Site indices to update, shape ``[M]``. + new_scales: New scale to apply per selected site, shape ``[M]``. + shape_body: Per-shape parent body index, shape ``[num_shapes]``. + num_shapes: Total number of shapes in the model. + shape_scale: Per-shape scale vectors to write into (modified in-place), + shape ``[num_shapes]``. + """ + i = wp.tid() + si = indices[i] + bid = site_body[si] + for s in range(num_shapes): + if shape_body[s] == bid: + shape_scale[s] = new_scales[i] + + +# ------------------------------------------------------------------ +# World-pose site_local write kernels +# ------------------------------------------------------------------ + + +@wp.kernel +def _write_site_local_from_world_poses( + body_q: wp.array(dtype=wp.transformf), + site_body: wp.array(dtype=wp.int32), + world_pos: wp.array(dtype=wp.vec3f), + world_quat: wp.array(dtype=wp.vec4f), + site_local: wp.array(dtype=wp.transformf), +): + """Update site local offsets so that the sites reach desired world poses. + + For each site *i*, computes + ``site_local[i] = inv(body_q[site_body[i]]) * desired_world`` so that + a subsequent ``body_q[bid] * site_local[i]`` yields the requested world + pose. For world-attached sites (``site_body[i] == -1``) the desired world + transform is written directly into ``site_local[i]``. + + Does **not** modify ``body_q``. + + Args: + body_q: Rigid-body world transforms from the Newton state, shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + world_pos: Desired world positions [m], shape ``[num_sites]``. + world_quat: Desired world orientations as ``(qx, qy, qz, qw)``, shape ``[num_sites]``. + site_local: Per-site local offset (modified in-place), shape ``[num_sites]``. + """ + i = wp.tid() + w_pos = world_pos[i] + w_q = world_quat[i] + desired_world = wp.transform(w_pos, wp.quatf(w_q[0], w_q[1], w_q[2], w_q[3])) + + bid = site_body[i] + if bid == -1: + site_local[i] = desired_world + else: + site_local[i] = wp.transform_multiply(wp.transform_inverse(body_q[bid]), desired_world) + + +@wp.kernel +def _write_site_local_from_world_poses_indexed( + body_q: wp.array(dtype=wp.transformf), + site_body: wp.array(dtype=wp.int32), + indices: wp.array(dtype=wp.int32), + world_pos: wp.array(dtype=wp.vec3f), + world_quat: wp.array(dtype=wp.vec4f), + site_local: wp.array(dtype=wp.transformf), +): + """Indexed variant of :func:`_write_site_local_from_world_poses`. + + Args: + body_q: Rigid-body world transforms from the Newton state, shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + indices: Site indices to update, shape ``[M]``. + world_pos: Desired world positions [m], shape ``[M]``. + world_quat: Desired world orientations as ``(qx, qy, qz, qw)``, shape ``[M]``. + site_local: Per-site local offset (modified in-place), shape ``[num_sites]``. + """ + i = wp.tid() + si = indices[i] + w_pos = world_pos[i] + w_q = world_quat[i] + desired_world = wp.transform(w_pos, wp.quatf(w_q[0], w_q[1], w_q[2], w_q[3])) + + bid = site_body[si] + if bid == -1: + site_local[si] = desired_world + else: + site_local[si] = wp.transform_multiply(wp.transform_inverse(body_q[bid]), desired_world) + + +# ------------------------------------------------------------------ +# Local-pose Warp kernels +# ------------------------------------------------------------------ + + +@wp.kernel +def _compute_site_local_transforms( + body_q: wp.array(dtype=wp.transformf), + site_body: wp.array(dtype=wp.int32), + site_local: wp.array(dtype=wp.transformf), + parent_site_body: wp.array(dtype=wp.int32), + parent_site_local: wp.array(dtype=wp.transformf), + out_pos: wp.array(dtype=wp.vec3f), + out_quat: wp.array(dtype=wp.vec4f), +): + """Compute parent-relative transforms for every site in the view. + + For each site *i*, computes the world pose of both the site and its USD + parent, then returns ``inv(parent_world) * prim_world``. When + ``site_body[i] == -1`` the site is world-attached and ``site_local[i]`` + is used as the world transform directly. The same convention applies to + the parent arrays. + + Args: + body_q: Rigid-body world transforms from the Newton state, shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + site_local: Per-site local offset relative to its parent body, shape ``[num_sites]``. + parent_site_body: Per-site USD-parent body index, shape ``[num_sites]``. + parent_site_local: Per-site USD-parent local offset, shape ``[num_sites]``. + out_pos: Output parent-relative positions [m], shape ``[num_sites]``. + out_quat: Output parent-relative orientations as ``(qx, qy, qz, qw)``, + shape ``[num_sites]``. + """ + i = wp.tid() + prim_bid = site_body[i] + if prim_bid == -1: + prim_world = site_local[i] + else: + prim_world = wp.transform_multiply(body_q[prim_bid], site_local[i]) + + parent_bid = parent_site_body[i] + if parent_bid == -1: + parent_world = parent_site_local[i] + else: + parent_world = wp.transform_multiply(body_q[parent_bid], parent_site_local[i]) + + local_tf = wp.transform_multiply(wp.transform_inverse(parent_world), prim_world) + out_pos[i] = wp.transform_get_translation(local_tf) + q = wp.transform_get_rotation(local_tf) + out_quat[i] = wp.vec4f(q[0], q[1], q[2], q[3]) + + +@wp.kernel +def _compute_site_local_transforms_indexed( + body_q: wp.array(dtype=wp.transformf), + site_body: wp.array(dtype=wp.int32), + site_local: wp.array(dtype=wp.transformf), + parent_site_body: wp.array(dtype=wp.int32), + parent_site_local: wp.array(dtype=wp.transformf), + indices: wp.array(dtype=wp.int32), + out_pos: wp.array(dtype=wp.vec3f), + out_quat: wp.array(dtype=wp.vec4f), +): + """Indexed variant of :func:`_compute_site_local_transforms`. + + Args: + body_q: Rigid-body world transforms from the Newton state, shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + site_local: Per-site local offset relative to its parent body, shape ``[num_sites]``. + parent_site_body: Per-site USD-parent body index, shape ``[num_sites]``. + parent_site_local: Per-site USD-parent local offset, shape ``[num_sites]``. + indices: Site indices to query, shape ``[M]``. + out_pos: Output parent-relative positions [m], shape ``[M]``. + out_quat: Output parent-relative orientations as ``(qx, qy, qz, qw)``, + shape ``[M]``. + """ + i = wp.tid() + si = indices[i] + prim_bid = site_body[si] + if prim_bid == -1: + prim_world = site_local[si] + else: + prim_world = wp.transform_multiply(body_q[prim_bid], site_local[si]) + + parent_bid = parent_site_body[si] + if parent_bid == -1: + parent_world = parent_site_local[si] + else: + parent_world = wp.transform_multiply(body_q[parent_bid], parent_site_local[si]) + + local_tf = wp.transform_multiply(wp.transform_inverse(parent_world), prim_world) + out_pos[i] = wp.transform_get_translation(local_tf) + q = wp.transform_get_rotation(local_tf) + out_quat[i] = wp.vec4f(q[0], q[1], q[2], q[3]) + + +@wp.kernel +def _write_site_local_from_local_poses( + body_q: wp.array(dtype=wp.transformf), + site_body: wp.array(dtype=wp.int32), + parent_site_body: wp.array(dtype=wp.int32), + parent_site_local: wp.array(dtype=wp.transformf), + local_pos: wp.array(dtype=wp.vec3f), + local_quat: wp.array(dtype=wp.vec4f), + site_local: wp.array(dtype=wp.transformf), +): + """Update site local offsets so that sites reach desired parent-relative poses. + + For each site *i*, reconstructs the desired world pose as + ``parent_world * desired_local``, then solves for the body-relative offset: + ``site_local[i] = inv(body_q[bid]) * desired_world``. For world-attached + sites (``site_body[i] == -1``) the world transform is written directly. + + Does **not** modify ``body_q``. + + Args: + body_q: Rigid-body world transforms from the Newton state, shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + parent_site_body: Per-site USD-parent body index, shape ``[num_sites]``. + parent_site_local: Per-site USD-parent local offset, shape ``[num_sites]``. + local_pos: Desired parent-relative positions [m], shape ``[num_sites]``. + local_quat: Desired parent-relative orientations as ``(qx, qy, qz, qw)``, + shape ``[num_sites]``. + site_local: Per-site local offset (modified in-place), shape ``[num_sites]``. + """ + i = wp.tid() + parent_bid = parent_site_body[i] + if parent_bid == -1: + parent_world = parent_site_local[i] + else: + parent_world = wp.transform_multiply(body_q[parent_bid], parent_site_local[i]) + + l_pos = local_pos[i] + l_q = local_quat[i] + local_tf = wp.transform(l_pos, wp.quatf(l_q[0], l_q[1], l_q[2], l_q[3])) + desired_world = wp.transform_multiply(parent_world, local_tf) + + bid = site_body[i] + if bid == -1: + site_local[i] = desired_world + else: + site_local[i] = wp.transform_multiply(wp.transform_inverse(body_q[bid]), desired_world) + + +@wp.kernel +def _write_site_local_from_local_poses_indexed( + body_q: wp.array(dtype=wp.transformf), + site_body: wp.array(dtype=wp.int32), + parent_site_body: wp.array(dtype=wp.int32), + parent_site_local: wp.array(dtype=wp.transformf), + indices: wp.array(dtype=wp.int32), + local_pos: wp.array(dtype=wp.vec3f), + local_quat: wp.array(dtype=wp.vec4f), + site_local: wp.array(dtype=wp.transformf), +): + """Indexed variant of :func:`_write_site_local_from_local_poses`. + + Args: + body_q: Rigid-body world transforms from the Newton state, shape ``[num_bodies]``. + site_body: Per-site body index (flat model-level), shape ``[num_sites]``. + parent_site_body: Per-site USD-parent body index, shape ``[num_sites]``. + parent_site_local: Per-site USD-parent local offset, shape ``[num_sites]``. + indices: Site indices to update, shape ``[M]``. + local_pos: Desired parent-relative positions [m], shape ``[M]``. + local_quat: Desired parent-relative orientations as ``(qx, qy, qz, qw)``, + shape ``[M]``. + site_local: Per-site local offset (modified in-place), shape ``[num_sites]``. + """ + i = wp.tid() + si = indices[i] + parent_bid = parent_site_body[si] + if parent_bid == -1: + parent_world = parent_site_local[si] + else: + parent_world = wp.transform_multiply(body_q[parent_bid], parent_site_local[si]) + + l_pos = local_pos[i] + l_q = local_quat[i] + local_tf = wp.transform(l_pos, wp.quatf(l_q[0], l_q[1], l_q[2], l_q[3])) + desired_world = wp.transform_multiply(parent_world, local_tf) + + bid = site_body[si] + if bid == -1: + site_local[si] = desired_world + else: + site_local[si] = wp.transform_multiply(wp.transform_inverse(body_q[bid]), desired_world) + + +# ------------------------------------------------------------------ +# View class +# ------------------------------------------------------------------ + + +class NewtonSiteFrameView(BaseFrameView): + """Batched prim view for non-physics prims tracked as sites on Newton bodies. + + Each matched USD prim must be a **non-physics** prim (camera, sensor, + Xform marker, etc.) that sits as a child of a Newton rigid body in the + USD hierarchy. The prim path must **not** resolve directly to a physics + body or collision shape -- those are owned by Newton and should be + accessed through :class:`~isaaclab_newton.assets.Articulation` or + :class:`~isaaclab_newton.assets.RigidObject` instead. + + At init time each prim is resolved to a ``(body_index, site_local)`` + pair via ancestor walk: the nearest ancestor that appears in + ``model.body_label`` becomes the attachment body, and the relative USD + transform becomes the site offset. If no body ancestor exists the prim + is attached to the world frame (``body_index = -1``). + + World poses are computed on GPU as + ``body_q[body_index] * site_local`` via a Warp kernel. Both + ``set_world_poses`` and ``set_local_poses`` update ``site_local`` -- + neither touches ``body_q``. + + All getters return ``wp.array``. Setters accept ``wp.array``. + + Raises: + ValueError: If any matched prim resolves to a Newton physics body + or collision shape. + """ + + def __init__(self, prim_path: str, device: str = "cpu", stage: Usd.Stage | None = None, **kwargs): + """Initialize the Newton site-based frame view. + + Resolves all USD prims matching ``prim_path`` and, for each one, walks + the USD ancestor hierarchy to find the nearest Newton rigid body. The + relative transform between the prim and its ancestor body becomes the + site's local offset. + + If the Newton model is already finalized the view initializes + immediately; otherwise initialization is deferred to a + :attr:`PhysicsEvent.PHYSICS_READY` callback. + + Args: + prim_path: USD prim path pattern (may contain regex). + device: Warp device for GPU arrays (e.g. ``"cuda:0"``). + stage: USD stage to search. Defaults to the current stage. + **kwargs: Unused; accepted for interface compatibility with other + :class:`~isaaclab.sim.views.BaseFrameView` backends. + """ + self._prim_path = prim_path + self._device = device + + stage = sim_utils.get_current_stage() if stage is None else stage + self._prims: list[Usd.Prim] = sim_utils.find_matching_prims(prim_path, stage=stage) + + model = NewtonManager.get_model() + if model is not None: + self._initialize_impl(model) + else: + self._physics_ready_handle = NewtonManager.register_callback( + self._on_physics_ready, PhysicsEvent.PHYSICS_READY, name=f"site_view_{prim_path}" + ) + + def _on_physics_ready(self, _event) -> None: + """Callback invoked when the Newton model becomes available.""" + self._initialize_impl(NewtonManager.get_model()) + + def _initialize_impl(self, model) -> None: + """Resolve USD prims to Newton body indices and allocate GPU buffers.""" + body_labels = list(model.body_label) + body_label_set = set(body_labels) + body_label_to_idx = {path: idx for idx, path in enumerate(body_labels)} + shape_label_set = set(model.shape_label) + + xform_cache = UsdGeom.XformCache(Usd.TimeCode.Default()) + + site_bodies: list[int] = [] + site_locals: list[list[float]] = [] + parent_bodies: list[int] = [] + parent_locals: list[list[float]] = [] + + identity_xform = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] + resolve_cache: dict[str, tuple[int, list[float]]] = {} + + for prim in self._prims: + pp = prim.GetPath().pathString + if pp in body_label_set: + raise ValueError( + f"FrameView prim '{pp}' is a Newton physics body. " + "FrameView should only be used for non-physics prims (cameras, sensors, Xform markers). " + "Use Articulation or RigidObject APIs to control physics bodies." + ) + if pp in shape_label_set: + raise ValueError( + f"FrameView prim '{pp}' is a Newton collision shape. " + "FrameView should only be used for non-physics prims (cameras, sensors, Xform markers). " + "Use Articulation or RigidObject APIs to control collision shapes." + ) + + body_idx, local_xform = self._resolve_ancestor_body(prim, body_label_to_idx, xform_cache) + site_bodies.append(body_idx) + site_locals.append(local_xform) + + parent = prim.GetParent() + if not parent or not parent.IsValid() or parent.GetPath().pathString == "/": + parent_bodies.append(WORLD_BODY_INDEX) + parent_locals.append(identity_xform) + else: + parent_path = parent.GetPath().pathString + if parent_path in resolve_cache: + pb_idx, pb_local = resolve_cache[parent_path] + elif parent_path in body_label_to_idx: + pb_idx = body_label_to_idx[parent_path] + pb_local = identity_xform + resolve_cache[parent_path] = (pb_idx, pb_local) + else: + pb_idx, pb_local = self._resolve_ancestor_body(parent, body_label_to_idx, xform_cache) + resolve_cache[parent_path] = (pb_idx, pb_local) + parent_bodies.append(pb_idx) + parent_locals.append(pb_local) + + 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 site_locals], + dtype=wp.transformf, + device=device, + ) + self._parent_site_body = wp.array(parent_bodies, dtype=wp.int32, device=device) + self._parent_site_local = wp.array( + [wp.transform(*x) for x in parent_locals], + dtype=wp.transformf, + device=device, + ) + + self._pos_buf = wp.zeros(self.count, dtype=wp.vec3f, device=device) + self._quat_buf = wp.zeros(self.count, dtype=wp.vec4f, device=device) + self._local_pos_buf = wp.zeros(self.count, dtype=wp.vec3f, device=device) + self._local_quat_buf = wp.zeros(self.count, dtype=wp.vec4f, device=device) + + @staticmethod + def _resolve_ancestor_body( + prim: Usd.Prim, + body_label_to_idx: dict[str, int], + xform_cache: UsdGeom.XformCache, + ) -> tuple[int, list[float]]: + """Walk USD ancestors to find the nearest Newton body and compute the relative local transform. + + Args: + prim: The USD prim to resolve. + body_label_to_idx: Dict mapping body prim paths to their Newton body indices. + xform_cache: USD xform cache for efficient transform lookups. + + Returns: + A tuple ``(body_index, local_xform_7)`` where *local_xform_7* is + ``[tx, ty, tz, qx, qy, qz, qw]``. If no body ancestor exists, + ``body_index`` is :data:`WORLD_BODY_INDEX` and the local transform + is the prim's world transform. + """ + prim_world_tf = xform_cache.GetLocalToWorldTransform(prim) + prim_world_tf.Orthonormalize() + + ancestor = prim.GetParent() + while ancestor and ancestor.IsValid() and ancestor.GetPath().pathString != "/": + ancestor_path = ancestor.GetPath().pathString + body_idx = body_label_to_idx.get(ancestor_path) + if body_idx is not None: + ancestor_world_tf = xform_cache.GetLocalToWorldTransform(ancestor) + ancestor_world_tf.Orthonormalize() + local_tf = prim_world_tf * ancestor_world_tf.GetInverse() + return body_idx, _gf_matrix_to_xform7(local_tf) + ancestor = ancestor.GetParent() + + return WORLD_BODY_INDEX, _gf_matrix_to_xform7(prim_world_tf) + + @property + def prims(self) -> list: + """List of USD prims being managed by this view.""" + return self._prims + + @property + def count(self) -> int: + """Number of prims in this view.""" + return len(self._prims) + + # ------------------------------------------------------------------ + # World poses + # ------------------------------------------------------------------ + + def get_world_poses(self, indices: wp.array | None = None) -> tuple[wp.array, wp.array]: + """Get world-space positions and orientations. + + Args: + indices: Subset of sites to query. ``None`` means all sites. + + Returns: + A tuple ``(positions, orientations)`` as ``wp.array`` of shapes + ``(M, 3)`` and ``(M, 4)`` respectively. + """ + state = NewtonManager.get_state_0() + + if indices is not None: + n = len(indices) + pos_buf = wp.zeros(n, dtype=wp.vec3f, device=self._device) + quat_buf = wp.zeros(n, dtype=wp.vec4f, device=self._device) + wp.launch( + _compute_site_world_transforms_indexed, + dim=n, + inputs=[state.body_q, self._site_body, self._site_local, indices], + outputs=[pos_buf, quat_buf], + device=self._device, + ) + return pos_buf, quat_buf + + wp.launch( + _compute_site_world_transforms, + dim=self.count, + inputs=[state.body_q, self._site_body, self._site_local], + outputs=[self._pos_buf, self._quat_buf], + device=self._device, + ) + return self._pos_buf, self._quat_buf + + def set_world_poses( + self, + positions: wp.array | None = None, + orientations: wp.array | None = None, + indices: wp.array | None = None, + ) -> None: + """Set world-space positions and/or orientations. + + Updates the internal ``site_local`` offsets so that + ``body_q[body] * new_site_local`` yields the desired world pose. + Does **not** modify ``body_q``. + + Args: + positions: Desired world positions ``(M, 3)``. ``None`` leaves + positions unchanged. + orientations: Desired world quaternions ``(M, 4)`` as + ``(qx, qy, qz, qw)``. ``None`` leaves orientations unchanged. + indices: Subset of sites to update. ``None`` means all sites. + """ + if positions is None and orientations is None: + return + + state = NewtonManager.get_state_0() + + if positions is None or orientations is None: + cur_pos, cur_quat = self.get_world_poses(indices) + if positions is None: + positions = cur_pos + if orientations is None: + orientations = cur_quat + + if indices is not None: + wp.launch( + _write_site_local_from_world_poses_indexed, + dim=len(indices), + inputs=[state.body_q, self._site_body, indices, positions, orientations, self._site_local], + device=self._device, + ) + else: + wp.launch( + _write_site_local_from_world_poses, + dim=self.count, + inputs=[state.body_q, self._site_body, positions, orientations, self._site_local], + device=self._device, + ) + + # ------------------------------------------------------------------ + # Local poses (parent-relative) + # ------------------------------------------------------------------ + + def get_local_poses(self, indices: wp.array | None = None) -> tuple[wp.array, wp.array]: + """Get parent-relative positions and orientations. + + Computes ``inv(parent_world) * prim_world`` for each site. + + Args: + indices: Subset of sites to query. ``None`` means all sites. + + Returns: + A tuple ``(translations, orientations)`` as ``wp.array`` of shapes + ``(M, 3)`` and ``(M, 4)`` respectively. + """ + state = NewtonManager.get_state_0() + + if indices is not None: + n = len(indices) + pos_buf = wp.zeros(n, dtype=wp.vec3f, device=self._device) + quat_buf = wp.zeros(n, dtype=wp.vec4f, device=self._device) + wp.launch( + _compute_site_local_transforms_indexed, + dim=n, + inputs=[ + state.body_q, + self._site_body, + self._site_local, + self._parent_site_body, + self._parent_site_local, + indices, + ], + outputs=[pos_buf, quat_buf], + device=self._device, + ) + return pos_buf, quat_buf + + wp.launch( + _compute_site_local_transforms, + dim=self.count, + inputs=[ + state.body_q, + self._site_body, + self._site_local, + self._parent_site_body, + self._parent_site_local, + ], + outputs=[self._local_pos_buf, self._local_quat_buf], + device=self._device, + ) + return self._local_pos_buf, self._local_quat_buf + + def set_local_poses( + self, + translations: wp.array | None = None, + orientations: wp.array | None = None, + indices: wp.array | None = None, + ) -> None: + """Set parent-relative translations and/or orientations. + + Updates the internal ``site_local`` offsets so that + ``inv(parent_world) * (body_q[bid] * site_local)`` yields the desired + local pose. Does **not** modify ``body_q``. + + Args: + translations: Desired parent-relative translations ``(M, 3)``. + ``None`` leaves translations unchanged. + orientations: Desired parent-relative quaternions ``(M, 4)`` as + ``(qx, qy, qz, qw)``. ``None`` leaves orientations unchanged. + indices: Subset of sites to update. ``None`` means all sites. + """ + if translations is None and orientations is None: + return + + state = NewtonManager.get_state_0() + + if translations is None or orientations is None: + cur_pos, cur_quat = self.get_local_poses(indices) + if translations is None: + translations = cur_pos + if orientations is None: + orientations = cur_quat + + if indices is not None: + wp.launch( + _write_site_local_from_local_poses_indexed, + dim=len(indices), + inputs=[ + state.body_q, + self._site_body, + self._parent_site_body, + self._parent_site_local, + indices, + translations, + orientations, + self._site_local, + ], + device=self._device, + ) + else: + wp.launch( + _write_site_local_from_local_poses, + dim=self.count, + inputs=[ + state.body_q, + self._site_body, + self._parent_site_body, + self._parent_site_local, + translations, + orientations, + self._site_local, + ], + device=self._device, + ) + + # ------------------------------------------------------------------ + # Scales + # ------------------------------------------------------------------ + + def get_scales(self, indices: wp.array | None = None) -> wp.array: + """Get per-site scales by reading from the first collision shape on the same body. + + Args: + indices: Subset of sites to query. ``None`` means all sites. + + Returns: + A ``wp.array`` of shape ``(M, 3)``. + """ + model = NewtonManager.get_model() + num_shapes = model.shape_count + + if indices is not None: + n = len(indices) + out = wp.zeros(n, dtype=wp.vec3f, device=self._device) + wp.launch( + _gather_scales_indexed, + dim=n, + inputs=[model.shape_scale, model.shape_body, self._site_body, indices, num_shapes], + outputs=[out], + device=self._device, + ) + else: + out = wp.zeros(self.count, dtype=wp.vec3f, device=self._device) + wp.launch( + _gather_scales, + dim=self.count, + inputs=[model.shape_scale, model.shape_body, self._site_body, num_shapes], + outputs=[out], + device=self._device, + ) + return out + + def set_scales(self, scales: wp.array, indices: wp.array | None = None) -> None: + """Set per-site scales by writing to all collision shapes on the same body. + + Args: + scales: New scales ``(M, 3)`` as ``wp.array``. + indices: Subset of sites to update. ``None`` means all sites. + """ + model = NewtonManager.get_model() + num_shapes = model.shape_count + + if indices is not None: + wp.launch( + _scatter_scales_indexed, + dim=len(indices), + inputs=[self._site_body, indices, scales, model.shape_body, num_shapes, model.shape_scale], + device=self._device, + ) + else: + wp.launch( + _scatter_scales, + dim=self.count, + inputs=[self._site_body, scales, model.shape_body, num_shapes, model.shape_scale], + device=self._device, + ) + + +def _gf_matrix_to_xform7(mat: Gf.Matrix4d) -> list[float]: + """Convert a ``Gf.Matrix4d`` to ``[tx, ty, tz, qx, qy, qz, qw]``.""" + t = mat.ExtractTranslation() + q = mat.ExtractRotationQuat() + imag = q.GetImaginary() + return [float(t[0]), float(t[1]), float(t[2]), float(imag[0]), float(imag[1]), float(imag[2]), float(q.GetReal())] diff --git a/source/isaaclab_newton/test/sim/__init__.py b/source/isaaclab_newton/test/sim/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_newton/test/sim/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause diff --git a/source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py b/source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py new file mode 100644 index 000000000000..9785b6d62e2e --- /dev/null +++ b/source/isaaclab_newton/test/sim/test_views_xform_prim_newton.py @@ -0,0 +1,198 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Newton backend tests for FrameView. + +Imports the shared contract tests and provides the Newton-specific +``view_factory`` fixture. Also includes Newton-only guard tests and +the world-attached prim edge case. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "isaaclab" / "test" / "sim")) + +import pytest +import torch +import warp as wp +from frame_view_contract_utils import * # noqa: F401, F403 — import all contract tests +from frame_view_contract_utils import CHILD_OFFSET, ViewBundle, _wp_vec3f, _wp_vec4f +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics.newton_manager import NewtonManager +from isaaclab_newton.sim.views import NewtonSiteFrameView as FrameView + +from pxr import Gf + +import isaaclab.sim as sim_utils +from isaaclab.assets import RigidObjectCfg +from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.sim import SimulationCfg, build_simulation_context +from isaaclab.utils import configclass + +NEWTON_SIM_CFG = SimulationCfg(physics=NewtonCfg(solver_cfg=MJWarpSolverCfg())) +WORLD_MARKER_POS = (5.0, 3.0, 1.0) + + +@configclass +class _SceneCfg(InteractiveSceneCfg): + cube: RigidObjectCfg = RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Cube", + spawn=sim_utils.CuboidCfg( + size=(0.2, 0.2, 0.2), + rigid_props=sim_utils.RigidBodyPropertiesCfg(), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, 1.0)), + ) + + +def _sim_context(device, num_envs=4): + NEWTON_SIM_CFG.device = device + return build_simulation_context(device=device, sim_cfg=NEWTON_SIM_CFG, add_ground_plane=True) + + +def _get_body_positions(num_envs, device="cpu"): + model = NewtonManager.get_model() + body_labels = list(model.body_label) + body_q_t = wp.to_torch(NewtonManager.get_state_0().body_q) + return torch.stack([body_q_t[body_labels.index(f"/World/envs/env_{i}/Cube"), :3] for i in range(num_envs)]) + + +def _set_body_positions(positions, num_envs): + model = NewtonManager.get_model() + body_labels = list(model.body_label) + body_q_t = wp.to_torch(NewtonManager.get_state_0().body_q) + for i in range(num_envs): + body_q_t[body_labels.index(f"/World/envs/env_{i}/Cube"), :3] = positions[i] + + +# ------------------------------------------------------------------ +# Contract fixture +# ------------------------------------------------------------------ + + +@pytest.fixture +def view_factory(): + """Newton factory: CameraMount child Xform at CHILD_OFFSET under each Cube body.""" + + def factory(num_envs: int, device: str) -> ViewBundle: + ctx = _sim_context(device, num_envs=num_envs) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + InteractiveScene(_SceneCfg(num_envs=num_envs, env_spacing=2.0)) + + stage = sim_utils.get_current_stage() + for i in range(num_envs): + prim = stage.DefinePrim(f"/World/envs/env_{i}/Cube/CameraMount", "Xform") + sim_utils.standardize_xform_ops(prim) + prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(*CHILD_OFFSET)) + prim.GetAttribute("xformOp:orient").Set(Gf.Quatd(1.0, 0.0, 0.0, 0.0)) + + sim.reset() + view = FrameView("/World/envs/env_.*/Cube/CameraMount", device=device) + + return ViewBundle( + view=view, + get_parent_pos=_get_body_positions, + set_parent_pos=_set_body_positions, + teardown=lambda: ctx.__exit__(None, None, None), + ) + + return factory + + +# ================================================================== +# Newton-only: guard tests +# ================================================================== + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_reject_body_path(device): + """FrameView rejects prim paths that resolve to a Newton physics body.""" + ctx = _sim_context(device, num_envs=2) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + InteractiveScene(_SceneCfg(num_envs=2, env_spacing=2.0)) + sim.reset() + + with pytest.raises(ValueError, match="physics body"): + FrameView("/World/envs/env_.*/Cube", device=device) + ctx.__exit__(None, None, None) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_reject_shape_path(device): + """FrameView rejects prim paths that resolve to a Newton collision shape.""" + ctx = _sim_context(device, num_envs=2) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + InteractiveScene(_SceneCfg(num_envs=2, env_spacing=2.0)) + sim.reset() + + shape_labels = list(NewtonManager.get_model().shape_label) + if not shape_labels: + pytest.skip("No shapes in model") + + with pytest.raises(ValueError, match="collision shape"): + FrameView(shape_labels[0], device=device) + ctx.__exit__(None, None, None) + + +# ================================================================== +# Newton edge case: world-attached prim (body=-1) +# ================================================================== + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_attached_returns_initial_pose(device): + """A world-rooted Xform returns its USD-authored position.""" + ctx = _sim_context(device, num_envs=2) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + InteractiveScene(_SceneCfg(num_envs=2, env_spacing=2.0)) + + stage = sim_utils.get_current_stage() + prim = stage.DefinePrim("/World/StaticMarker", "Xform") + sim_utils.standardize_xform_ops(prim) + prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(*WORLD_MARKER_POS)) + prim.GetAttribute("xformOp:orient").Set(Gf.Quatd(1.0, 0.0, 0.0, 0.0)) + + sim.reset() + view = FrameView("/World/StaticMarker", device=device) + + pos = wp.to_torch(view.get_world_poses()[0]) + expected = torch.tensor([list(WORLD_MARKER_POS)], device=device) + torch.testing.assert_close(pos, expected, atol=1e-5, rtol=0) + ctx.__exit__(None, None, None) + + +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_world_attached_set_world_roundtrip(device): + """A world-attached prim can be repositioned via set_world_poses.""" + ctx = _sim_context(device, num_envs=2) + sim = ctx.__enter__() + sim._app_control_on_stop_handle = None + InteractiveScene(_SceneCfg(num_envs=2, env_spacing=2.0)) + + stage = sim_utils.get_current_stage() + prim = stage.DefinePrim("/World/StaticMarker", "Xform") + sim_utils.standardize_xform_ops(prim) + prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(*WORLD_MARKER_POS)) + prim.GetAttribute("xformOp:orient").Set(Gf.Quatd(1.0, 0.0, 0.0, 0.0)) + + sim.reset() + view = FrameView("/World/StaticMarker", device=device) + + new_pos = _wp_vec3f([[10.0, 20.0, 30.0]], device=device) + new_quat = _wp_vec4f([[0.0, 0.0, 0.0, 1.0]], device=device) + view.set_world_poses(new_pos, new_quat) + + ret_pos, ret_quat = view.get_world_poses() + torch.testing.assert_close(wp.to_torch(ret_pos), wp.to_torch(new_pos), atol=1e-5, rtol=0) + torch.testing.assert_close(wp.to_torch(ret_quat), wp.to_torch(new_quat), atol=1e-5, rtol=0) + ctx.__exit__(None, None, None) diff --git a/source/isaaclab_physx/config/extension.toml b/source/isaaclab_physx/config/extension.toml index d05b38808199..f9368e59a0f5 100644 --- a/source/isaaclab_physx/config/extension.toml +++ b/source/isaaclab_physx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.20" +version = "0.5.21" # Description title = "PhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_physx/docs/CHANGELOG.rst b/source/isaaclab_physx/docs/CHANGELOG.rst index d722acb0687a..a343bf0367fd 100644 --- a/source/isaaclab_physx/docs/CHANGELOG.rst +++ b/source/isaaclab_physx/docs/CHANGELOG.rst @@ -1,6 +1,22 @@ Changelog --------- +0.5.21 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added :class:`~isaaclab_physx.sim.views.XformPrimView` providing the PhysX/Fabric + backend implementation for xform prim views. + +Changed +^^^^^^^ + +* Renamed :class:`~isaaclab_physx.sim.views.FabricXformPrimView` to + :class:`~isaaclab_physx.sim.views.FabricFrameView`. Old name is kept as a deprecated alias. + + 0.5.20 (2026-04-21) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py index ceb6089dc2ce..766137753dfe 100644 --- a/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py +++ b/source/isaaclab_physx/isaaclab_physx/scene_data_providers/physx_scene_data_provider.py @@ -54,7 +54,7 @@ class PhysxSceneDataProvider(BaseSceneDataProvider): """Scene data provider for Omni PhysX backend. Supports: - - body poses via PhysX tensor views, with XformPrimView fallback + - body poses via PhysX tensor views, with FrameView fallback - camera poses & intrinsics - USD stage handles - Newton model/state handles @@ -560,13 +560,13 @@ def _apply_view_poses(self, view: Any, view_key: str, positions: Any, orientatio return count def _apply_xform_poses(self, positions: Any, orientations: Any, covered: Any, xform_mask: Any) -> int: - """Fill remaining poses using XformPrimView (USD fallback). + """Fill remaining poses using FrameView (USD fallback). This is slower but more robust when PhysX views don't cover all bodies. """ import torch - from isaaclab.sim.views import XformPrimView + from isaaclab.sim.views import FrameView uncovered = torch.where(~covered)[0].cpu().tolist() if not uncovered: @@ -578,14 +578,14 @@ def _apply_xform_poses(self, positions: Any, orientations: Any, covered: Any, xf path = self._rigid_body_paths[idx] try: if path not in self._xform_views: - self._xform_views[path] = XformPrimView( + self._xform_views[path] = FrameView( path, device=self._device, stage=self._stage, validate_xform_ops=False ) - pos, quat = self._xform_views[path].get_world_poses() - if pos is not None and quat is not None: - positions[idx] = pos.to(device=self._device, dtype=torch.float32).squeeze() - orientations[idx] = quat.to(device=self._device, dtype=torch.float32).squeeze() + pos_wp, quat_wp = self._xform_views[path].get_world_poses() + if pos_wp is not None and quat_wp is not None: + positions[idx] = wp.to_torch(pos_wp).to(device=self._device, dtype=torch.float32).squeeze() + orientations[idx] = wp.to_torch(quat_wp).to(device=self._device, dtype=torch.float32).squeeze() covered[idx] = True xform_mask[idx] = True count += 1 @@ -605,7 +605,7 @@ def _apply_xform_poses(self, positions: Any, orientations: Any, covered: Any, xf def _convert_xform_quats(self, orientations: Any, xform_mask: Any) -> Any: """Return quaternions in xyzw convention. - PhysX views, XformPrimView, and resolve_prim_pose() in Isaac Lab all use xyzw. + PhysX views, FrameView, and resolve_prim_pose() in Isaac Lab all use xyzw. Keeping this helper as a no-op preserves a single conversion point if conventions ever diverge again. """ diff --git a/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi index c75cccf3f04a..abc8d0087afd 100644 --- a/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi +++ b/source/isaaclab_physx/isaaclab_physx/sim/__init__.pyi @@ -11,6 +11,7 @@ __all__ = [ "spawn_deformable_body_material", "DeformableBodyMaterialCfg", "SurfaceDeformableBodyMaterialCfg", + "views", ] from .schemas import ( @@ -24,3 +25,4 @@ from .spawners import ( DeformableBodyMaterialCfg, SurfaceDeformableBodyMaterialCfg, ) +from . import views diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/__init__.py b/source/isaaclab_physx/isaaclab_physx/sim/views/__init__.py new file mode 100644 index 000000000000..85c69b44a24f --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""PhysX simulation views.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/__init__.pyi b/source/isaaclab_physx/isaaclab_physx/sim/views/__init__.pyi new file mode 100644 index 000000000000..789d62af9d14 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/__init__.pyi @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "FabricFrameView", +] + +from .fabric_frame_view import FabricFrameView diff --git a/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py new file mode 100644 index 000000000000..87adad2238c4 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/sim/views/fabric_frame_view.py @@ -0,0 +1,403 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""PhysX FrameView with Fabric GPU acceleration.""" + +from __future__ import annotations + +import logging + +import torch +import warp as wp + +from pxr import Usd + +import isaaclab.sim as sim_utils +from isaaclab.app.settings_manager import SettingsManager +from isaaclab.sim.views.base_frame_view import BaseFrameView +from isaaclab.sim.views.usd_frame_view import UsdFrameView +from isaaclab.utils.warp import fabric as fabric_utils + +logger = logging.getLogger(__name__) + + +def _to_float32_2d(a: wp.array | torch.Tensor) -> wp.array | torch.Tensor: + """Ensure array is compatible with Fabric kernels (2-D float32). + + For ``wp.array`` with vec dtypes (``vec3f``, ``vec4f``), uses + :meth:`wp.array.view` for zero-copy reinterpretation. + ``torch.Tensor`` and already-correct 2-D float32 arrays pass through. + """ + if not isinstance(a, wp.array): + return a + if a.shape[0] == 0: + return a + if a.ndim == 2 and a.dtype == wp.float32: + return a + return a.view(dtype=wp.float32) + + +class FabricFrameView(BaseFrameView): + """FrameView with Fabric GPU acceleration for the PhysX backend. + + Uses composition: holds a :class:`UsdFrameView` internally for USD + fallback and non-accelerated operations (local poses, visibility, scales + when Fabric is disabled). + + When Fabric is enabled, world-pose and scale operations use GPU-accelerated + Warp kernels operating on ``omni:fabric:worldMatrix``. All other operations + delegate to the internal USD view. + + All getters return ``wp.array``. Setters accept ``wp.array``. + """ + + def __init__( + self, + prim_path: str, + device: str = "cpu", + validate_xform_ops: bool = True, + sync_usd_on_fabric_write: bool = False, + stage: Usd.Stage | None = None, + ): + self._usd_view = UsdFrameView(prim_path, device=device, validate_xform_ops=validate_xform_ops, stage=stage) + self._device = device + self._sync_usd_on_fabric_write = sync_usd_on_fabric_write + + settings = SettingsManager.instance() + self._use_fabric = bool(settings.get("/physics/fabricEnabled", False)) + + if self._use_fabric and self._device == "cpu": + logger.warning( + "Fabric mode with Warp fabric-array operations is not supported on CPU devices. " + "Falling back to standard USD operations on the CPU. This may impact performance." + ) + self._use_fabric = False + + if self._use_fabric and self._device not in ("cuda", "cuda:0"): + logger.warning( + f"Fabric mode is not supported on device '{self._device}'. " + "USDRT SelectPrims and Warp fabric arrays only support cuda:0. " + "Falling back to standard USD operations. This may impact performance." + ) + self._use_fabric = False + + self._fabric_initialized = False + self._fabric_usd_sync_done = False + self._fabric_selection = None + self._fabric_to_view: wp.array | None = None + self._view_to_fabric: wp.array | None = None + self._default_view_indices: wp.array | None = None + self._fabric_hierarchy = None + self._view_index_attr = f"isaaclab:view_index:{abs(hash(self))}" + + # ------------------------------------------------------------------ + # Delegated properties + # ------------------------------------------------------------------ + + @property + def count(self) -> int: + return self._usd_view.count + + @property + def device(self) -> str: + return self._device + + @property + def prims(self) -> list: + return self._usd_view.prims + + @property + def prim_paths(self) -> list[str]: + return self._usd_view.prim_paths + + # ------------------------------------------------------------------ + # Delegated operations (USD-only) + # ------------------------------------------------------------------ + + def get_visibility(self, indices=None): + return self._usd_view.get_visibility(indices) + + def set_visibility(self, visibility, indices=None): + self._usd_view.set_visibility(visibility, indices) + + # ------------------------------------------------------------------ + # World poses — Fabric-accelerated or USD fallback + # ------------------------------------------------------------------ + + def set_world_poses(self, positions=None, orientations=None, indices=None): + if not self._use_fabric: + self._usd_view.set_world_poses(positions, orientations, indices) + return + + if not self._fabric_initialized: + self._initialize_fabric() + + indices_wp = self._resolve_indices_wp(indices) + count = indices_wp.shape[0] + + dummy = wp.zeros((0, 3), dtype=wp.float32, device=self._device) + positions_wp = _to_float32_2d(positions) if positions is not None else dummy + orientations_wp = ( + _to_float32_2d(orientations) + if orientations is not None + else wp.zeros((0, 4), dtype=wp.float32, device=self._device) + ) + + wp.launch( + kernel=fabric_utils.compose_fabric_transformation_matrix_from_warp_arrays, + dim=count, + inputs=[ + self._fabric_world_matrices, + positions_wp, + orientations_wp, + dummy, + False, + False, + False, + indices_wp, + self._view_to_fabric, + ], + device=self._fabric_device, + ) + wp.synchronize() + + self._fabric_hierarchy.update_world_xforms() + self._fabric_usd_sync_done = True + if self._sync_usd_on_fabric_write: + self._usd_view.set_world_poses(positions, orientations, indices) + + def get_world_poses(self, indices=None): + if not self._use_fabric: + return self._usd_view.get_world_poses(indices) + + if not self._fabric_initialized: + self._initialize_fabric() + if not self._fabric_usd_sync_done: + self._sync_fabric_from_usd_once() + + indices_wp = self._resolve_indices_wp(indices) + count = indices_wp.shape[0] + + use_cached = indices is None or indices == slice(None) + if use_cached: + positions_wp = self._fabric_positions_buf + orientations_wp = self._fabric_orientations_buf + else: + positions_wp = wp.zeros((count, 3), dtype=wp.float32, device=self._device) + orientations_wp = wp.zeros((count, 4), dtype=wp.float32, device=self._device) + + wp.launch( + kernel=fabric_utils.decompose_fabric_transformation_matrix_to_warp_arrays, + dim=count, + inputs=[ + self._fabric_world_matrices, + positions_wp, + orientations_wp, + self._fabric_dummy_buffer, + indices_wp, + self._view_to_fabric, + ], + device=self._fabric_device, + ) + + if use_cached: + wp.synchronize() + return positions_wp, orientations_wp + + # ------------------------------------------------------------------ + # Local poses — USD fallback (Fabric only accelerates world poses) + # ------------------------------------------------------------------ + + def set_local_poses(self, translations=None, orientations=None, indices=None): + self._usd_view.set_local_poses(translations, orientations, indices) + + def get_local_poses(self, indices=None): + return self._usd_view.get_local_poses(indices) + + # ------------------------------------------------------------------ + # Scales — Fabric-accelerated or USD fallback + # ------------------------------------------------------------------ + + def set_scales(self, scales, indices=None): + if not self._use_fabric: + self._usd_view.set_scales(scales, indices) + return + + if not self._fabric_initialized: + self._initialize_fabric() + + indices_wp = self._resolve_indices_wp(indices) + count = indices_wp.shape[0] + + dummy3 = wp.zeros((0, 3), dtype=wp.float32, device=self._device) + dummy4 = wp.zeros((0, 4), dtype=wp.float32, device=self._device) + scales_wp = _to_float32_2d(scales) + + wp.launch( + kernel=fabric_utils.compose_fabric_transformation_matrix_from_warp_arrays, + dim=count, + inputs=[ + self._fabric_world_matrices, + dummy3, + dummy4, + scales_wp, + False, + False, + False, + indices_wp, + self._view_to_fabric, + ], + device=self._fabric_device, + ) + wp.synchronize() + + self._fabric_hierarchy.update_world_xforms() + self._fabric_usd_sync_done = True + if self._sync_usd_on_fabric_write: + self._usd_view.set_scales(scales, indices) + + def get_scales(self, indices=None): + if not self._use_fabric: + return self._usd_view.get_scales(indices) + + if not self._fabric_initialized: + self._initialize_fabric() + if not self._fabric_usd_sync_done: + self._sync_fabric_from_usd_once() + + indices_wp = self._resolve_indices_wp(indices) + count = indices_wp.shape[0] + + use_cached = indices is None or indices == slice(None) + if use_cached: + scales_wp = self._fabric_scales_buf + else: + scales_wp = wp.zeros((count, 3), dtype=wp.float32, device=self._device) + + wp.launch( + kernel=fabric_utils.decompose_fabric_transformation_matrix_to_warp_arrays, + dim=count, + inputs=[ + self._fabric_world_matrices, + self._fabric_dummy_buffer, + self._fabric_dummy_buffer, + scales_wp, + indices_wp, + self._view_to_fabric, + ], + device=self._fabric_device, + ) + + if use_cached: + wp.synchronize() + return scales_wp + + # ------------------------------------------------------------------ + # Internal — Fabric initialization + # ------------------------------------------------------------------ + + def _initialize_fabric(self) -> None: + """Initialize Fabric batch infrastructure for GPU-accelerated pose queries.""" + import usdrt # noqa: PLC0415 + from usdrt import Rt # noqa: PLC0415 + + stage_id = sim_utils.get_current_stage_id() + fabric_stage = usdrt.Usd.Stage.Attach(stage_id) + + for i in range(self.count): + rt_prim = fabric_stage.GetPrimAtPath(self.prim_paths[i]) + rt_xformable = Rt.Xformable(rt_prim) + + has_attr = ( + rt_xformable.HasFabricHierarchyWorldMatrixAttr() + if hasattr(rt_xformable, "HasFabricHierarchyWorldMatrixAttr") + else False + ) + if not has_attr: + rt_xformable.CreateFabricHierarchyWorldMatrixAttr() + + rt_xformable.SetWorldXformFromUsd() + + rt_prim.CreateAttribute(self._view_index_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True) + rt_prim.GetAttribute(self._view_index_attr).Set(i) + + self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( + fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId() + ) + self._fabric_hierarchy.update_world_xforms() + + self._default_view_indices = wp.zeros((self.count,), dtype=wp.uint32, device=self._device) + wp.launch( + kernel=fabric_utils.arange_k, dim=self.count, inputs=[self._default_view_indices], device=self._device + ) + wp.synchronize() + + fabric_device = self._device + if self._device == "cuda": + logger.warning("Fabric device is not specified, defaulting to 'cuda:0'.") + fabric_device = "cuda:0" + elif self._device.startswith("cuda:"): + if self._device != "cuda:0": + logger.debug( + f"SelectPrims only supports cuda:0. Using cuda:0 for SelectPrims " + f"even though simulation device is {self._device}." + ) + fabric_device = "cuda:0" + + self._fabric_selection = fabric_stage.SelectPrims( + require_attrs=[ + (usdrt.Sdf.ValueTypeNames.UInt, self._view_index_attr, usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite), + ], + device=fabric_device, + ) + + self._view_to_fabric = wp.zeros((self.count,), dtype=wp.uint32, device=fabric_device) + self._fabric_to_view = wp.fabricarray(self._fabric_selection, self._view_index_attr) + + wp.launch( + kernel=fabric_utils.set_view_to_fabric_array, + dim=self._fabric_to_view.shape[0], + inputs=[self._fabric_to_view, self._view_to_fabric], + device=fabric_device, + ) + wp.synchronize() + + self._fabric_positions_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) + self._fabric_orientations_buf = wp.zeros((self.count, 4), dtype=wp.float32, device=self._device) + self._fabric_scales_buf = wp.zeros((self.count, 3), dtype=wp.float32, device=self._device) + self._fabric_dummy_buffer = wp.zeros((0, 3), dtype=wp.float32, device=self._device) + self._fabric_world_matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") + self._fabric_stage = fabric_stage + self._fabric_device = fabric_device + + self._fabric_initialized = True + self._fabric_usd_sync_done = False + + def _sync_fabric_from_usd_once(self) -> None: + """Sync Fabric world matrices from USD once, on the first read.""" + if not self._fabric_initialized: + self._initialize_fabric() + + positions_usd, orientations_usd = self._usd_view.get_world_poses() + scales_usd = self._usd_view.get_scales() + + prev_sync = self._sync_usd_on_fabric_write + self._sync_usd_on_fabric_write = False + self.set_world_poses(positions_usd, orientations_usd) + self.set_scales(scales_usd) + self._sync_usd_on_fabric_write = prev_sync + + self._fabric_usd_sync_done = True + + def _resolve_indices_wp(self, indices: wp.array | None) -> wp.array: + """Resolve view indices as a Warp uint32 array.""" + if indices is None or indices == slice(None): + if self._default_view_indices is None: + raise RuntimeError("Fabric indices are not initialized.") + return self._default_view_indices + if indices.dtype != wp.uint32: + return wp.array(indices.numpy().astype("uint32"), dtype=wp.uint32, device=self._device) + return indices diff --git a/source/isaaclab_physx/test/sim/__init__.py b/source/isaaclab_physx/test/sim/__init__.py new file mode 100644 index 000000000000..460a30569089 --- /dev/null +++ b/source/isaaclab_physx/test/sim/__init__.py @@ -0,0 +1,4 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py new file mode 100644 index 000000000000..0bc77ccf7223 --- /dev/null +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -0,0 +1,105 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""PhysX Fabric backend tests for FrameView. + +Imports the shared contract tests and provides the Fabric-specific +``view_factory`` fixture (SimulationContext with use_fabric=True, +Camera prim type for Fabric SelectPrims compatibility). +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "isaaclab" / "test" / "sim")) + +from isaaclab.app import AppLauncher + +simulation_app = AppLauncher(headless=True).app + +import pytest # noqa: E402 +import torch # noqa: E402 +from frame_view_contract_utils import * # noqa: F401, F403, E402 +from frame_view_contract_utils import CHILD_OFFSET, ViewBundle # noqa: E402 +from isaaclab_physx.sim.views import FabricFrameView as FrameView # noqa: E402 + +from pxr import Gf, UsdGeom # noqa: E402 + +import isaaclab.sim as sim_utils # noqa: E402 + +PARENT_POS = (0.0, 0.0, 1.0) + + +@pytest.fixture(autouse=True) +def test_setup_teardown(): + sim_utils.create_new_stage() + sim_utils.update_stage() + yield + sim_utils.clear_stage() + sim_utils.SimulationContext.clear_instance() + + +def _skip_if_unavailable(device: str): + if device.startswith("cuda") and not torch.cuda.is_available(): + pytest.skip("CUDA not available") + if device == "cpu": + pytest.skip("Warp fabricarray operations on CPU have known issues") + + +# ------------------------------------------------------------------ +# Parent position helpers (via USD xformOps) +# ------------------------------------------------------------------ + + +def _get_parent_positions(num_envs, device="cpu"): + stage = sim_utils.get_current_stage() + xform_cache = UsdGeom.XformCache() + positions = [] + for i in range(num_envs): + prim = stage.GetPrimAtPath(f"/World/Parent_{i}") + tf = xform_cache.GetLocalToWorldTransform(prim) + t = tf.ExtractTranslation() + positions.append([float(t[0]), float(t[1]), float(t[2])]) + return torch.tensor(positions, dtype=torch.float32, device=device) + + +def _set_parent_positions(positions, num_envs): + from pxr import Sdf # noqa: PLC0415 + + stage = sim_utils.get_current_stage() + with Sdf.ChangeBlock(): + for i in range(num_envs): + prim = stage.GetPrimAtPath(f"/World/Parent_{i}") + pos = positions[i] + prim.GetAttribute("xformOp:translate").Set(Gf.Vec3d(float(pos[0]), float(pos[1]), float(pos[2]))) + + +# ------------------------------------------------------------------ +# Contract fixture +# ------------------------------------------------------------------ + + +@pytest.fixture +def view_factory(): + """Fabric factory: Camera child at CHILD_OFFSET under parent Xforms, with Fabric enabled.""" + + def factory(num_envs: int, device: str) -> ViewBundle: + _skip_if_unavailable(device) + + stage = sim_utils.get_current_stage() + for i in range(num_envs): + sim_utils.create_prim(f"/World/Parent_{i}", "Xform", translation=PARENT_POS, stage=stage) + sim_utils.create_prim(f"/World/Parent_{i}/Child", "Camera", translation=CHILD_OFFSET, stage=stage) + + sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True)) + view = FrameView("/World/Parent_.*/Child", device=device, sync_usd_on_fabric_write=True) + return ViewBundle( + view=view, + get_parent_pos=_get_parent_positions, + set_parent_pos=_set_parent_positions, + teardown=lambda: None, + ) + + return factory diff --git a/source/isaaclab_tasks/config/extension.toml b/source/isaaclab_tasks/config/extension.toml index bc0640c4cbd6..81334689ea9f 100644 --- a/source/isaaclab_tasks/config/extension.toml +++ b/source/isaaclab_tasks/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "1.5.23" +version = "1.5.24" # Description title = "Isaac Lab Environments" diff --git a/source/isaaclab_tasks/docs/CHANGELOG.rst b/source/isaaclab_tasks/docs/CHANGELOG.rst index b0cf36c9369b..c81768405791 100644 --- a/source/isaaclab_tasks/docs/CHANGELOG.rst +++ b/source/isaaclab_tasks/docs/CHANGELOG.rst @@ -1,6 +1,20 @@ Changelog --------- +1.5.24 (2026-04-22) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Updated locomotion :class:`~isaaclab.sensors.ray_caster.ray_caster_cfg.RayCasterCfg` + height-scanner defaults to spawn a ``raycaster`` Xform child under the robot attachment link + (using :class:`~isaaclab.sim.spawners.sensors.sensors_cfg.RayCasterXformCfg`) so the sensor + works with Newton site-based :class:`~isaaclab.sim.views.FrameView` tracking. +* Updated all sensor configurations to use :class:`~isaaclab.sim.views.FrameView` instead of + the deprecated ``XformPrimView``. + + 1.5.23 (2026-04-21) ~~~~~~~~~~~~~~~~~~~ @@ -10,6 +24,7 @@ Fixed * Refreshed Newton Warp renderer golden images for Dexsuite Kuka-Allegro environment case in ``test_rendering_correctness`` because Newton Warp renderer honors visibility of prims now. + 1.5.22 (2026-04-20) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/terminations.py index 2b87dc69df76..8e530a7d71e0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomanipulation/pick_place/mdp/terminations.py @@ -51,7 +51,7 @@ def task_done_pick_place_table_frame( env: The RL environment instance. task_link_name: Name of the right wrist link on the robot. object_cfg: Configuration for the object entity. - table_cfg: Configuration for the destination table entity (must be an XformPrimView). + table_cfg: Configuration for the destination table entity (must be a FrameView). right_wrist_max_x: Maximum x position of the right wrist in table frame for task completion. min_x: Minimum x position of the object relative to the table for task completion. max_x: Maximum x position of the object relative to the table for task completion. diff --git a/source/isaaclab_teleop/test/test_oxr_device.py b/source/isaaclab_teleop/test/test_oxr_device.py index 2d2ed8444969..1663a56612d9 100644 --- a/source/isaaclab_teleop/test/test_oxr_device.py +++ b/source/isaaclab_teleop/test/test_oxr_device.py @@ -179,12 +179,12 @@ def test_xr_anchor(empty_env, mock_xrcore): device = OpenXRDevice(OpenXRDeviceCfg(xr_cfg=env_cfg.xr)) # Check that the xr anchor prim is created with the correct pose - xr_anchor_view = sim_utils.XformPrimView("/World/XRAnchor") + xr_anchor_view = sim_utils.FrameView("/World/XRAnchor") assert xr_anchor_view.count == 1 position, orientation = xr_anchor_view.get_world_poses() np.testing.assert_almost_equal(position.numpy(), [[1, 2, 3]]) - # XformPrimView returns quaternion in xyzw format, identity is [0, 0, 0, 1] + # FrameView returns quaternion in xyzw format, identity is [0, 0, 0, 1] np.testing.assert_almost_equal(orientation.numpy(), [[0, 0, 0, 1]]) # Check that xr anchor mode and custom anchor are set correctly @@ -202,7 +202,7 @@ def test_xr_anchor_default(empty_env, mock_xrcore): device = OpenXRDevice(OpenXRDeviceCfg()) # Check that the xr anchor prim is created with the correct default pose - xr_anchor_view = sim_utils.XformPrimView("/World/XRAnchor") + xr_anchor_view = sim_utils.FrameView("/World/XRAnchor") assert xr_anchor_view.count == 1 position, orientation = xr_anchor_view.get_world_poses() @@ -225,7 +225,7 @@ def test_xr_anchor_multiple_devices(empty_env, mock_xrcore): device_2 = OpenXRDevice(OpenXRDeviceCfg()) # Check that the xr anchor prim is created with the correct default pose - xr_anchor_view = sim_utils.XformPrimView("/World/XRAnchor") + xr_anchor_view = sim_utils.FrameView("/World/XRAnchor") assert xr_anchor_view.count == 1 position, orientation = xr_anchor_view.get_world_poses() From 0c565d63a9e6c6169cdec1ae13eaf000487dc666 Mon Sep 17 00:00:00 2001 From: hougantc-nvda <127865892+hougantc-nvda@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:03:21 -0400 Subject: [PATCH 34/37] Caches resolve_matching_names on AssetBase for all finder methods (#5202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description `Articulation.find_joints` and `find_bodies` delegate to `resolve_matching_names`, which runs a Python regex double-loop on every call. Nsight profiling of the pick-place task showed this costs ~881Ξs per call (~1.83 ms / 1.5% of step time) for work that always returns the same result — joint and body names are fixed after construction. This PR adds a module-level `@functools.cache` on a new private `_resolve_matching_names_impl` helper. Hashable tuples are used for the cache key; cached results are immutable tuples that the public wrapper copies into fresh lists per caller, so callers cannot mutate shared state. `resolve_matching_names_values` is left uncached because its current callers only use it during init, never in the step loop. A `clear_resolve_matching_names_cache()` helper is called from `SimulationContext.clear_instance()` so cached entries from destroyed assets do not accumulate across scene rebuilds in long-lived processes. Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes # (issue) ## Type of change - New feature (non-breaking change which adds functionality) ## Screenshots Please attach before and after screenshots of the change if applicable. ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Antoine Richard --- .gitignore | 3 + source/isaaclab/config/extension.toml | 2 +- source/isaaclab/docs/CHANGELOG.rst | 11 ++ .../isaaclab/sim/simulation_context.py | 4 + source/isaaclab/isaaclab/utils/__init__.pyi | 2 + source/isaaclab/isaaclab/utils/string.py | 123 +++++++++++------- .../test/assets/test_articulation_iface.py | 66 ++++++++++ source/isaaclab/test/utils/test_string.py | 20 +++ source/isaaclab_newton/config/extension.toml | 2 +- source/isaaclab_newton/docs/CHANGELOG.rst | 10 ++ .../test/assets/test_rigid_object.py | 4 +- source/isaaclab_ovphysx/config/extension.toml | 2 +- source/isaaclab_ovphysx/docs/CHANGELOG.rst | 18 +++ .../assets/articulation/articulation.py | 46 ++----- 14 files changed, 226 insertions(+), 87 deletions(-) diff --git a/.gitignore b/.gitignore index 4b345b0a7d24..60989ad5dd1b 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ _build # Isaac Lab CI environments in native mode **/_isaaclab_install_ci_* + +# Superpowers (Claude Code plugin artifacts) +docs/superpowers/ diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 86024cf07e6a..3086a2b93c88 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.6.11" +version = "4.6.12" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 489be9f6aa15..d5af850f2412 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +4.6.12 (2026-04-23) +~~~~~~~~~~~~~~~~~~~ + +Added +^^^^^ + +* Added caching to :func:`~isaaclab.utils.string.resolve_matching_names`, + avoiding repeated regex matching across ``find_bodies``, ``find_joints``, + and related calls. + + 4.6.11 (2026-04-22) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index fa5427bcb24f..4de0cd2c9840 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -29,6 +29,7 @@ resolve_scene_data_requirements, ) from isaaclab.sim.utils import create_new_stage +from isaaclab.utils.string import clear_resolve_matching_names_cache from isaaclab.utils.version import has_kit from isaaclab.visualizers.base_visualizer import BaseVisualizer @@ -835,6 +836,9 @@ def clear_instance(cls) -> None: # close_stage() + app shutdown destroy the entire stage at once. stage_utils.close_stage() + # Discard cached name-resolution data from destroyed assets + clear_resolve_matching_names_cache() + # Clear instance cls._instance = None diff --git a/source/isaaclab/isaaclab/utils/__init__.pyi b/source/isaaclab/isaaclab/utils/__init__.pyi index 84d6e8b7f098..1ca7ef7866c6 100644 --- a/source/isaaclab/isaaclab/utils/__init__.pyi +++ b/source/isaaclab/isaaclab/utils/__init__.pyi @@ -46,6 +46,7 @@ __all__ = [ "string_to_callable", "ResolvableString", "resolve_matching_names", + "clear_resolve_matching_names_cache", "resolve_matching_names_values", "find_unique_string_name", "find_root_prim_path_from_regex", @@ -98,6 +99,7 @@ from .string import ( string_to_callable, ResolvableString, resolve_matching_names, + clear_resolve_matching_names_cache, resolve_matching_names_values, find_unique_string_name, find_root_prim_path_from_regex, diff --git a/source/isaaclab/isaaclab/utils/string.py b/source/isaaclab/isaaclab/utils/string.py index c4033055d8a3..4e7790006ade 100644 --- a/source/isaaclab/isaaclab/utils/string.py +++ b/source/isaaclab/isaaclab/utils/string.py @@ -6,6 +6,7 @@ """Sub-module containing utilities for transforming strings and regular expressions.""" import ast +import functools import importlib import inspect import re @@ -247,50 +248,19 @@ def __deepcopy__(self, memo): """ -def resolve_matching_names( - keys: str | Sequence[str], - list_of_strings: Sequence[str], - preserve_order: bool = False, - *, - raise_when_no_match: bool = True, -) -> tuple[list[int], list[str]]: - """Match a list of query regular expressions against a list of strings and return the matched indices and names. - - When a list of query regular expressions is provided, the function checks each target string against each - query regular expression and returns the indices of the matched strings and the matched strings. - - If the :attr:`preserve_order` is True, the ordering of the matched indices and names is the same as the order - of the provided list of strings. This means that the ordering is dictated by the order of the target strings - and not the order of the query regular expressions. - - If the :attr:`preserve_order` is False, the ordering of the matched indices and names is the same as the order - of the provided list of query regular expressions. - - For example, consider the list of strings is ['a', 'b', 'c', 'd', 'e'] and the regular expressions are ['a|c', 'b']. - If :attr:`preserve_order` is False, then the function will return the indices of the matched strings and the - strings as: ([0, 1, 2], ['a', 'b', 'c']). When :attr:`preserve_order` is True, it will return them as: - ([0, 2, 1], ['a', 'c', 'b']). +@functools.cache +def _resolve_matching_names_impl( + keys: tuple[str, ...], + list_of_strings: tuple[str, ...], + preserve_order: bool, + raise_when_no_match: bool, +) -> tuple[tuple[int, ...], tuple[str, ...]]: + """Cached implementation of :func:`resolve_matching_names`. - Note: - The function does not sort the indices. It returns the indices in the order they are found. - - Args: - keys: A regular expression or a list of regular expressions to match the strings in the list. - list_of_strings: A list of strings to match. - preserve_order: Whether to preserve the order of the query keys in the returned values. Defaults to False. - raise_when_no_match: Whether to raise a ``ValueError`` when not all regular expressions are matched. - Defaults to True. When False, returns empty lists instead of raising. - - Returns: - A tuple of lists containing the matched indices and names. - - Raises: - ValueError: When multiple matches are found for a string in the list. - ValueError: When not all regular expressions are matched and :attr:`raise_when_no_match` is True. + All arguments are hashable so that ``functools.cache`` can store results. + Returns tuples (immutable) to protect the cached data from mutation; + the public wrapper converts these back to fresh lists for each caller. """ - # resolve name keys - if isinstance(keys, str): - keys = [keys] # find matching patterns index_list = [] names_list = [] @@ -337,7 +307,7 @@ def resolve_matching_names( # check that all regular expressions are matched if not all(keys_match_found): if not raise_when_no_match: - return [], [] + return (), () # make this print nicely aligned for debugging msg = "\n" for key, value in zip(keys, keys_match_found): @@ -347,8 +317,66 @@ def resolve_matching_names( raise ValueError( f"Not all regular expressions are matched! Please check that the regular expressions are correct: {msg}" ) - # return - return index_list, names_list + # return immutable tuples for safe caching + return tuple(index_list), tuple(names_list) + + +def resolve_matching_names( + keys: str | Sequence[str], + list_of_strings: Sequence[str], + preserve_order: bool = False, + *, + raise_when_no_match: bool = True, +) -> tuple[list[int], list[str]]: + """Match a list of query regular expressions against a list of strings and return the matched indices and names. + + When a list of query regular expressions is provided, the function checks each target string against each + query regular expression and returns the indices of the matched strings and the matched strings. + + If the :attr:`preserve_order` is True, the ordering of the matched indices and names is the same as the order + of the provided list of strings. This means that the ordering is dictated by the order of the target strings + and not the order of the query regular expressions. + + If the :attr:`preserve_order` is False, the ordering of the matched indices and names is the same as the order + of the provided list of query regular expressions. + + For example, consider the list of strings is ['a', 'b', 'c', 'd', 'e'] and the regular expressions are ['a|c', 'b']. + If :attr:`preserve_order` is False, then the function will return the indices of the matched strings and the + strings as: ([0, 1, 2], ['a', 'b', 'c']). When :attr:`preserve_order` is True, it will return them as: + ([0, 2, 1], ['a', 'c', 'b']). + + Results are cached internally — repeated calls with the same arguments avoid redundant regex matching. + + Note: + The function does not sort the indices. It returns the indices in the order they are found. + + Args: + keys: A regular expression or a list of regular expressions to match the strings in the list. + list_of_strings: A list of strings to match. + preserve_order: Whether to preserve the order of the query keys in the returned values. Defaults to False. + raise_when_no_match: Whether to raise a ``ValueError`` when not all regular expressions are matched. + Defaults to True. When False, returns empty lists instead of raising. + + Returns: + A tuple of lists containing the matched indices and names. + + Raises: + ValueError: When multiple matches are found for a string in the list. + ValueError: When not all regular expressions are matched and :attr:`raise_when_no_match` is True. + """ + _keys = (keys,) if isinstance(keys, str) else tuple(keys) + idx, names = _resolve_matching_names_impl(_keys, tuple(list_of_strings), preserve_order, raise_when_no_match) + return list(idx), list(names) + + +def clear_resolve_matching_names_cache() -> None: + """Discard all cached results from :func:`resolve_matching_names`. + + Call this when the simulation scene is torn down so that cached + name-resolution entries from destroyed assets do not accumulate + across scene rebuilds in long-lived processes. + """ + _resolve_matching_names_impl.cache_clear() def resolve_matching_names_values( @@ -360,6 +388,11 @@ def resolve_matching_names_values( """Match a list of regular expressions in a dictionary against a list of strings and return the matched indices, names, and values. + Note: + Unlike :func:`resolve_matching_names`, this function is not cached. Current callers + use it during initialization only (e.g. action/actuator config resolution), so caching + would add complexity without a measurable benefit. + If the :attr:`preserve_order` is True, the ordering of the matched indices and names is the same as the order of the provided list of strings. This means that the ordering is dictated by the order of the target strings and not the order of the query regular expressions. diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 14af782c2a5b..cbec2781065b 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -602,6 +602,72 @@ def test_find_joints_single(self, backend, num_instances, num_joints, num_bodies assert names == [first_joint] +# --------------------------------------------------------------------------- +# Tests: resolve_matching_names caching behavior +# --------------------------------------------------------------------------- + + +_non_mock_backends = pytest.mark.parametrize("backend", [b for b in BACKENDS if b != "mock"], indirect=False) + + +class TestResolveMatchingNamesCache: + """Test that resolve_matching_names caching returns correct, isolated results.""" + + @_non_mock_backends + @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 6, 7)]) + @_default_devices + def test_unmatched_regex_raises(self, backend, num_instances, num_joints, num_bodies, device): + """ValueError from resolve_matching_names propagates correctly.""" + art, _ = get_articulation(backend, num_instances, num_joints, num_bodies, device=device) + with pytest.raises(ValueError): + art.find_bodies("nonexistent_body_xyz") + with pytest.raises(ValueError): + art.find_joints("nonexistent_joint_xyz") + + @_backends + @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 6, 7)]) + @_default_devices + def test_mutating_result_does_not_corrupt_cache( + self, backend, num_instances, num_joints, num_bodies, device, articulation_iface + ): + """Mutating returned lists must not affect future cached results.""" + art, _ = articulation_iface + + for finder, expected_len in [("find_bodies", num_bodies), ("find_joints", num_joints)]: + idx1, names1 = getattr(art, finder)(".*") + assert len(idx1) == expected_len + + idx1.clear() + names1.append("corrupted") + + idx2, names2 = getattr(art, finder)(".*") + assert len(idx2) == expected_len + assert "corrupted" not in names2 + + @_non_mock_backends + @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 6, 7)]) + @_default_devices + def test_find_with_multiple_patterns(self, backend, num_instances, num_joints, num_bodies, device): + """Passing a list of regex patterns works correctly.""" + art, _ = get_articulation(backend, num_instances, num_joints, num_bodies, device=device) + idx, names = art.find_joints(["joint_0", "joint_1"]) + assert "joint_0" in names + assert "joint_1" in names + assert len(names) == 2 + + @_non_mock_backends + @pytest.mark.parametrize("num_instances, num_joints, num_bodies", [(2, 6, 7)]) + @_default_devices + def test_find_with_preserve_order(self, backend, num_instances, num_joints, num_bodies, device): + """preserve_order=True returns names in the order of the input patterns.""" + art, _ = get_articulation(backend, num_instances, num_joints, num_bodies, device=device) + idx_fwd, names_fwd = art.find_joints(["joint_1", "joint_0"], preserve_order=True) + assert names_fwd == ["joint_1", "joint_0"] + + idx_rev, names_rev = art.find_joints(["joint_0", "joint_1"], preserve_order=True) + assert names_rev == ["joint_0", "joint_1"] + + # --------------------------------------------------------------------------- # Tests: ArticulationData root state properties # --------------------------------------------------------------------------- diff --git a/source/isaaclab/test/utils/test_string.py b/source/isaaclab/test/utils/test_string.py index ce443dec705b..22f51ab6f483 100644 --- a/source/isaaclab/test/utils/test_string.py +++ b/source/isaaclab/test/utils/test_string.py @@ -19,6 +19,7 @@ import pytest import isaaclab.utils.string as string_utils +from isaaclab.utils.string import _resolve_matching_names_impl def test_resolvable_string_metadata_is_non_eager(): @@ -251,3 +252,22 @@ def test_resolve_matching_names_values_with_basic_strings_and_preserved_order(): query_names = {"a|c": 1, "b": 0, "f": 2} with pytest.raises(ValueError): _ = string_utils.resolve_matching_names_values(query_names, target_names, preserve_order=True) + + +def test_clear_resolve_matching_names_cache(): + """Clearing the cache discards previously cached entries.""" + target_names = ["a", "b", "c"] + # Populate the cache + string_utils.resolve_matching_names("a", target_names) + info_before = _resolve_matching_names_impl.cache_info() + assert info_before.currsize > 0 + + # Clear the cache + string_utils.clear_resolve_matching_names_cache() + info_after = _resolve_matching_names_impl.cache_info() + assert info_after.currsize == 0 + + # Results are still correct after clearing + idx, names = string_utils.resolve_matching_names("a", target_names) + assert idx == [0] + assert names == ["a"] diff --git a/source/isaaclab_newton/config/extension.toml b/source/isaaclab_newton/config/extension.toml index 8a95afe963a0..7d5691efdeb4 100644 --- a/source/isaaclab_newton/config/extension.toml +++ b/source/isaaclab_newton/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.5.20" +version = "0.5.21" # Description title = "Newton simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_newton/docs/CHANGELOG.rst b/source/isaaclab_newton/docs/CHANGELOG.rst index 3e6cdd8cd115..ec1f45bec732 100644 --- a/source/isaaclab_newton/docs/CHANGELOG.rst +++ b/source/isaaclab_newton/docs/CHANGELOG.rst @@ -1,6 +1,16 @@ Changelog --------- +0.5.21 (2026-04-23) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed flakiness in ``test_body_root_state_properties`` by bounding the random spin velocity so + numerical drift stays within the position tolerance over the simulated trajectory. + + 0.5.20 (2026-04-22) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_newton/test/assets/test_rigid_object.py b/source/isaaclab_newton/test/assets/test_rigid_object.py index 138b23b9fb4b..152f55d8c6f4 100644 --- a/source/isaaclab_newton/test/assets/test_rigid_object.py +++ b/source/isaaclab_newton/test/assets/test_rigid_object.py @@ -948,9 +948,9 @@ def test_body_root_state_properties(num_cubes, device, with_offset): # check center of mass has been set torch.testing.assert_close(wp.to_torch(cube_object.data.body_com_pos_b).squeeze(1), offset) - # random z spin velocity + # random z spin velocity (bounded to keep numerical drift within the position tolerance below) spin_twist = torch.zeros(6, device=device) - spin_twist[5] = torch.randn(1, device=device) + spin_twist[5] = 0.5 * torch.randn(1, device=device).clamp(-1.0, 1.0) # Simulate physics for _ in range(100): diff --git a/source/isaaclab_ovphysx/config/extension.toml b/source/isaaclab_ovphysx/config/extension.toml index 11b3322f2f1d..ed4f5b39fb70 100644 --- a/source/isaaclab_ovphysx/config/extension.toml +++ b/source/isaaclab_ovphysx/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "0.1.0" +version = "0.1.1" # Description title = "OvPhysX simulation interfaces for IsaacLab core package" diff --git a/source/isaaclab_ovphysx/docs/CHANGELOG.rst b/source/isaaclab_ovphysx/docs/CHANGELOG.rst index b177752442d3..750a0397f23d 100644 --- a/source/isaaclab_ovphysx/docs/CHANGELOG.rst +++ b/source/isaaclab_ovphysx/docs/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog --------- +0.1.1 (2026-04-21) +~~~~~~~~~~~~~~~~~~~ + +Changed +^^^^^^^ + +* Replaced private ``_find_names`` (fnmatch + regex) with the standard + :func:`~isaaclab.utils.string.resolve_matching_names` for all finder + methods, unifying name-resolution behavior across backends. Fnmatch-style + glob patterns (e.g. ``joint_*``) are no longer supported; use regex + equivalents (e.g. ``joint_.*``). ``find_fixed_tendons`` and + ``find_spatial_tendons`` now raise ``ValueError`` on empty tendon lists, + matching the PhysX backend. +* Changed ``find_joints`` ``joint_subset`` parameter from ``list[int]`` + (indices) to ``list[str]`` (names) to match the ``BaseArticulation`` + interface. Callers passing indices should convert to names first. + + 0.1.0 (2026-04-20) ~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py index 4c00dc839ca1..7224d53d40ea 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/assets/articulation/articulation.py @@ -7,7 +7,6 @@ from __future__ import annotations -import fnmatch import logging import re from collections.abc import Sequence @@ -19,6 +18,7 @@ from isaaclab.assets.articulation.base_articulation import BaseArticulation from isaaclab.physics import PhysicsManager +from isaaclab.utils.string import resolve_matching_names from isaaclab.utils.wrench_composer import WrenchComposer from isaaclab_ovphysx import tensor_types as TT @@ -187,12 +187,12 @@ def find_bodies(self, name_keys: str | Sequence[str], preserve_order: bool = Fal Returns: A tuple of lists containing the body indices and names. """ - return self._find_names(self._body_names, name_keys, preserve_order) + return resolve_matching_names(name_keys, self._body_names, preserve_order) def find_joints( self, name_keys: str | Sequence[str], - joint_subset: list[int] | None = None, + joint_subset: list[str] | None = None, preserve_order: bool = False, ) -> tuple[list[int], list[str]]: """Find joints in the articulation based on the name keys. @@ -202,18 +202,16 @@ def find_joints( Args: name_keys: A regular expression or a list of regular expressions to match the joint names. - joint_subset: A subset of joint indices to search within. Defaults to None, which means all joints + joint_subset: A subset of joints to search for. Defaults to None, which means all joints in the articulation are searched. preserve_order: Whether to preserve the order of the name keys in the output. Defaults to False. Returns: A tuple of lists containing the joint indices and names. """ - names = [self._joint_names[i] for i in joint_subset] if joint_subset is not None else self._joint_names - indices, matched = self._find_names(names, name_keys, preserve_order) - if joint_subset is not None: - indices = [joint_subset[i] for i in indices] - return indices, matched + if joint_subset is None: + joint_subset = self._joint_names + return resolve_matching_names(name_keys, joint_subset, preserve_order) def find_fixed_tendons( self, @@ -237,9 +235,7 @@ def find_fixed_tendons( """ if tendon_subsets is None: tendon_subsets = self.fixed_tendon_names - if not tendon_subsets: - return [], [] - return self._find_names(tendon_subsets, name_keys, preserve_order) + return resolve_matching_names(name_keys, tendon_subsets, preserve_order) def find_spatial_tendons( self, @@ -262,9 +258,7 @@ def find_spatial_tendons( """ if tendon_subsets is None: tendon_subsets = self.spatial_tendon_names - if not tendon_subsets: - return [], [] - return self._find_names(tendon_subsets, name_keys, preserve_order) + return resolve_matching_names(name_keys, tendon_subsets, preserve_order) """ Operations - State Writers. @@ -2664,28 +2658,6 @@ def _nst(self): """Return the number of spatial tendons (0 if none).""" return getattr(self, "_num_spatial_tendons", 0) - @staticmethod - def _find_names(names: list[str], keys: str | Sequence[str], preserve_order: bool) -> tuple[list[int], list[str]]: - if isinstance(keys, str): - keys = [keys] - matched_indices: list[int] = [] - matched_names: list[str] = [] - if preserve_order: - for key in keys: - for idx, name in enumerate(names): - if fnmatch.fnmatch(name, key) or re.fullmatch(key, name): - if idx not in matched_indices: - matched_indices.append(idx) - matched_names.append(name) - else: - for idx, name in enumerate(names): - for key in keys: - if fnmatch.fnmatch(name, key) or re.fullmatch(key, name): - matched_indices.append(idx) - matched_names.append(name) - break - return matched_indices, matched_names - def _resolve_joint_values(self, pattern_dict: dict[str, float], buffer: wp.array) -> None: """Resolve a {pattern: value} dict into a per-joint buffer. From a0e07b43522b7818502bd522865a22e413a1cc79 Mon Sep 17 00:00:00 2001 From: Antoine RICHARD Date: Thu, 23 Apr 2026 15:04:49 +0200 Subject: [PATCH 35/37] Forbid pushing to origin in AGENTS.md (#5344) # Description The origin remote points to the public isaac-sim/IsaacLab repo. Agents and contributors should push to their own fork remote or to the remote of the PR they are working on instead. ## Type of change - Documentation update ## Checklist - [ ] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --- AGENTS.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 70c1d15158ab..91f858751f50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ We use a wrapped python call within `./isaaclab.sh`. ### Pre-commit (lint/format hooks) -**CRITICAL: Always run pre-commit hooks BEFORE committing, not after.** +**CRITICAL: Always run pre-commit hooks BEFORE committing and BEFORE pushing.** Proper workflow: 1. Make your code changes @@ -73,15 +73,17 @@ Proper workflow: 4. Stage the modified files with `git add` 5. Run `./isaaclab.sh -f` again to ensure all checks pass 6. Only then create your commit with `git commit` +7. Verify pre-commit still passes before pushing — never push commits that haven't been checked ```bash # Run pre-commit checks on all files ./isaaclab.sh -f ``` -**Common mistake to avoid:** +**Common mistakes to avoid:** - Don't commit first and then run pre-commit (requires amending commits) -- Do run pre-commit before committing (clean workflow) +- Don't push before running pre-commit (pushes broken code to the remote) +- Do run pre-commit before committing and before pushing (clean workflow) **When reviewing code** (e.g. via a code-reviewer agent), always run `./isaaclab.sh -f` as part of the review to catch formatting or lint issues early. @@ -152,6 +154,7 @@ Follow conventional commit message practices. ## Sandbox & Networking - Network access (e.g., `git push`) is blocked by the sandbox. Use `dangerouslyDisableSandbox: true` so the user gets an approval prompt — don't ask them to run it manually. +- **Never push to `origin` (`isaac-sim/IsaacLab`).** The `origin` remote is the public upstream repository. Push to your own fork remote (e.g., `antoine`, `alex`) or to the remote of the PR you are working on. If the correct remote is unclear, ask the user before pushing. ## GitHub Actions and CI/CD From 76b16897dd84b9b552dd4492959bb7f155a99cec Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Wed, 22 Apr 2026 16:57:39 +0000 Subject: [PATCH 36/37] tweak comments --- docs/source/features/visualization.rst | 2 +- source/isaaclab/isaaclab/visualizers/visualizer_cfg.py | 6 ++---- .../isaaclab_visualizers/kit/kit_visualizer.py | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index c093fe18f00f..2c84bd02fa95 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -366,7 +366,7 @@ server, allowing you to view and interact with the scene from any browser. Performance Note ---------------- -To reduce overhead when visualizing large-scale environments, consider: +When visualizing large-scale environments, consider: - Using Newton instead of Omniverse or Rerun - Reducing window sizes diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 3f62c3e5232e..74de203fa3c8 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -49,17 +49,15 @@ class VisualizerCfg: env_filter_mode: Literal["none", "env_ids", "random_n"] = "none" """Env filter mode: 'none', 'env_ids', or 'random_n'.""" - env_filter_random_n: int = 64 + env_filter_random_n: int = 16 """If env_filter_mode='random_n', number of envs to sample.""" env_filter_seed: int = 0 """Seed for deterministic env sampling.""" env_filter_ids: list[int] = [i for i in range(0, 64, 4)] - """If env_filter_mode='env_ids', only these env indices are shown. + """If env_filter_mode='env_ids', only these env indices are shown in visualizers. - This improves performance, particularly for large-scale training, by reducing scene updates sent to visualizers. - Note, OV visualizer only applies a cosmetic visibility toggle (no performance gain). """ def get_visualizer_type(self) -> str | None: diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 3ad3ffd01326..f071167a3fce 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -75,7 +75,7 @@ def initialize(self, scene_data_provider: BaseSceneDataProvider) -> None: self._env_ids = self._compute_visualized_env_ids() if self._env_ids: logger.warning( - "[KitVisualizer] env_filter_ids filtering is cosmetic only (no perf gain) in OV; hiding other envs." + "[KitVisualizer] With env_filter_ids, Kit uses visibility only and hides unselected env prims." ) self._apply_env_visibility(usd_stage, metadata) num_visualized_envs = len(self._env_ids) if self._env_ids is not None else int(metadata.get("num_envs", 0)) From caa5a2a0fdce64210069994a5377663553b8079f Mon Sep 17 00:00:00 2001 From: Matthew Trepte Date: Thu, 23 Apr 2026 19:08:18 +0000 Subject: [PATCH 37/37] fix failing tests --- docs/source/features/visualization.rst | 3 +-- .../isaaclab/visualizers/base_visualizer.py | 2 +- .../isaaclab/visualizers/visualizer_cfg.py | 4 ++-- .../sim/test_simulation_context_visualizers.py | 5 ++++- .../test/test_sim_launcher_visualizer_intent.py | 15 +++++++++------ 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/source/features/visualization.rst b/docs/source/features/visualization.rst index 407a692fbe15..b9d23a45cf91 100644 --- a/docs/source/features/visualization.rst +++ b/docs/source/features/visualization.rst @@ -360,7 +360,7 @@ server, allowing you to view and interact with the scene from any browser. open_browser=True, label="Isaac Lab Simulation", share=False, - max_worlds=64, + max_visible_envs=16, ) **Configuration options:** @@ -371,7 +371,6 @@ server, allowing you to view and interact with the scene from any browser. - ``share`` (bool, default ``False``): Request a public share URL from Viser for remote viewing. - ``record_to_viser`` (str or None, default ``None``): Path to save a ``.viser`` recording file. - ``verbose`` (bool, default ``True``): Print viewer server startup information. -- ``max_worlds`` (int or None, default ``None``): Maximum number of environments rendered. .. note:: diff --git a/source/isaaclab/isaaclab/visualizers/base_visualizer.py b/source/isaaclab/isaaclab/visualizers/base_visualizer.py index d6107d098524..b0fc5a81088f 100644 --- a/source/isaaclab/isaaclab/visualizers/base_visualizer.py +++ b/source/isaaclab/isaaclab/visualizers/base_visualizer.py @@ -136,7 +136,7 @@ def get_visualized_env_ids(self) -> list[int] | None: Returns: Visualized environment ids, or ``None`` for all environments. """ - return getattr(self, "_env_ids", None) + return self._env_ids def _compute_visualized_env_ids(self) -> list[int] | None: """Compute which environment indices to visualize from config. diff --git a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py index 510c65b70880..1ee4cde038b5 100644 --- a/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py +++ b/source/isaaclab/isaaclab/visualizers/visualizer_cfg.py @@ -57,10 +57,10 @@ class VisualizerCfg: """env indices to visualize in order (out-of-range indices are dropped).""" randomly_sample_visible_envs: bool = True - """If ``max_visible_envs`` is provided, if enabled, selected visible envs are randomly sampled. + """If ``max_visible_envs`` is provided, when enabled, selected visible envs are randomly sampled. If disabled, the first ``max_visible_envs`` envs are selected. - * Note ``visible_env_indices`` overrides this field. + * Note: ``visible_env_indices`` overrides this field. """ def get_visualizer_type(self) -> str | None: diff --git a/source/isaaclab/test/sim/test_simulation_context_visualizers.py b/source/isaaclab/test/sim/test_simulation_context_visualizers.py index fe1eb6f040b5..d5fa5ffbb2ce 100644 --- a/source/isaaclab/test/sim/test_simulation_context_visualizers.py +++ b/source/isaaclab/test/sim/test_simulation_context_visualizers.py @@ -35,6 +35,8 @@ def update(self): class _FakeVisualizer: + """Minimal visualizer for orchestration tests.""" + def __init__( self, *, @@ -219,7 +221,8 @@ def _fake_create_viewer(self, record_to_viser: str | None, metadata: dict | None assert visualizer._sim_time == pytest.approx(0.25) assert viewer.calls[0][0] == "begin_frame" assert viewer.calls[0][1] == pytest.approx(0.25) - assert viewer.calls[1] == ("log_state", {"state_call": 2, "env_ids": None}) + # log_state passes through get_newton_state() as-is; no env_ids (or other) keys are merged in. + assert viewer.calls[1] == ("log_state", {"state_call": 2}) assert viewer.calls[2] == ("end_frame",) diff --git a/source/isaaclab_tasks/test/test_sim_launcher_visualizer_intent.py b/source/isaaclab_tasks/test/test_sim_launcher_visualizer_intent.py index 853a9fb31a5d..c6bad5c19f1e 100644 --- a/source/isaaclab_tasks/test/test_sim_launcher_visualizer_intent.py +++ b/source/isaaclab_tasks/test/test_sim_launcher_visualizer_intent.py @@ -75,15 +75,18 @@ def set_bool(self, path: str, value: bool) -> None: monkeypatch.setattr( sim_launcher, "compute_kit_requirements", lambda env_cfg, launcher_args: (False, False, {"none"}) ) - monkeypatch.setitem( - sys.modules, - "isaaclab.app.settings_manager", - types.SimpleNamespace(get_settings_manager=lambda: _FakeSettings()), - ) + # `app_launcher` imports both names from settings_manager; provide a full stub module + # so `from isaaclab.app import AppLauncher` succeeds in kitless mode. + _sm = types.ModuleType("isaaclab.app.settings_manager") + _sm.get_settings_manager = lambda: _FakeSettings() + _sm.initialize_carb_settings = lambda: None + monkeypatch.setitem(sys.modules, "isaaclab.app.settings_manager", _sm) env_cfg = _DummyEnvCfg(_DummySimCfg(None)) launcher_args = argparse.Namespace(visualizer=["none"]) with sim_launcher.launch_simulation(env_cfg, launcher_args): pass - assert captured == {"types": "", "explicit": True, "disable_all": True} + # `sync_visualizer_cli_settings_to_carb` uses ``" ".join(visualizer)`` → ``"none"`` for ``["none"]``, + # not an empty string (empty only when *visualizer* is missing/empty). + assert captured == {"types": "none", "explicit": True, "disable_all": True}